diff --git a/Scripts/sportstime_parser/models/stadium.py b/Scripts/sportstime_parser/models/stadium.py index 1a4e366..9d40014 100644 --- a/Scripts/sportstime_parser/models/stadium.py +++ b/Scripts/sportstime_parser/models/stadium.py @@ -82,6 +82,7 @@ class Stadium: "primary_team_abbrevs": primary_team_abbrevs or [], "year_opened": self.opened_year, "timezone_identifier": self.timezone, + "image_url": self.image_url, } @classmethod @@ -118,6 +119,8 @@ class Stadium: longitude=data["longitude"], capacity=data.get("capacity"), opened_year=data.get("year_opened"), + image_url=data.get("image_url"), + timezone=data.get("timezone_identifier"), ) def to_json(self) -> str: diff --git a/Scripts/sportstime_parser/models/team.py b/Scripts/sportstime_parser/models/team.py index c2ba0a5..752c584 100644 --- a/Scripts/sportstime_parser/models/team.py +++ b/Scripts/sportstime_parser/models/team.py @@ -54,6 +54,20 @@ class Team: "stadium_id": self.stadium_id, } + def _make_qualified_id(self, name: Optional[str]) -> Optional[str]: + """Convert a conference/division name to a qualified ID. + + Examples: + "Eastern" → "nba_eastern" + "AL West" → "mlb_al_west" + "Southeast" → "nba_southeast" + """ + if not name: + return None + # Lowercase, replace spaces with underscores + normalized = name.lower().replace(" ", "_") + return f"{self.sport.lower()}_{normalized}" + def to_canonical_dict(self) -> dict: """Convert to canonical dictionary format matching iOS app schema. @@ -67,8 +81,8 @@ class Team: "sport": self.sport.upper(), # iOS Sport enum expects uppercase (e.g., "NFL") "city": self.city, "stadium_canonical_id": self.stadium_id or "", - "conference_id": self.conference, - "division_id": self.division, + "conference_id": self._make_qualified_id(self.conference), + "division_id": self._make_qualified_id(self.division), "primary_color": self.primary_color, "secondary_color": self.secondary_color, } @@ -91,9 +105,38 @@ class Team: stadium_id=data.get("stadium_id"), ) + @staticmethod + def _extract_name_from_qualified_id(qualified_id: Optional[str], sport: str) -> Optional[str]: + """Extract the name portion from a qualified ID. + + Examples: + "nba_eastern" → "Eastern" + "mlb_al_west" → "AL West" + "nba_southeast" → "Southeast" + """ + if not qualified_id: + return None + # Remove sport prefix (e.g., "nba_" or "mlb_") + prefix = f"{sport.lower()}_" + if qualified_id.startswith(prefix): + name = qualified_id[len(prefix):] + else: + name = qualified_id + # Convert underscores to spaces and title case + # Special handling for league abbreviations (AL, NL, etc.) + parts = name.split("_") + result_parts = [] + for part in parts: + if part.upper() in ("AL", "NL", "AFC", "NFC"): + result_parts.append(part.upper()) + else: + result_parts.append(part.capitalize()) + return " ".join(result_parts) + @classmethod def from_canonical_dict(cls, data: dict) -> "Team": """Create a Team from a canonical dictionary (iOS app format).""" + sport = data["sport"].lower() return cls( id=data["canonical_id"], sport=data["sport"], @@ -101,8 +144,8 @@ class Team: name=data["name"], full_name=f"{data['city']} {data['name']}", # Reconstruct full_name abbreviation=data["abbreviation"], - conference=data.get("conference_id"), - division=data.get("division_id"), + conference=cls._extract_name_from_qualified_id(data.get("conference_id"), sport), + division=cls._extract_name_from_qualified_id(data.get("division_id"), sport), primary_color=data.get("primary_color"), secondary_color=data.get("secondary_color"), stadium_id=data.get("stadium_canonical_id"), diff --git a/Scripts/sportstime_parser/normalizers/canonical_id.py b/Scripts/sportstime_parser/normalizers/canonical_id.py index ad319b6..e59c4f8 100644 --- a/Scripts/sportstime_parser/normalizers/canonical_id.py +++ b/Scripts/sportstime_parser/normalizers/canonical_id.py @@ -53,7 +53,7 @@ def generate_game_id( ) -> str: """Generate a canonical game ID. - Format: {sport}_{season}_{away}_{home}_{MMDD}[_{game_number}] + Format: game_{sport}_{season}_{YYYYMMDD}_{away}_{home}[_{game_number}] Args: sport: Sport code (e.g., 'nba', 'mlb') @@ -64,27 +64,27 @@ def generate_game_id( game_number: Game number for doubleheaders (1 or 2), None for single games Returns: - Canonical game ID (e.g., 'nba_2025_hou_okc_1021') + Canonical game ID (e.g., 'game_nba_2025_20251021_hou_okc') Examples: >>> generate_game_id('nba', 2025, 'HOU', 'OKC', date(2025, 10, 21)) - 'nba_2025_hou_okc_1021' + 'game_nba_2025_20251021_hou_okc' >>> generate_game_id('mlb', 2026, 'NYY', 'BOS', date(2026, 4, 1), game_number=1) - 'mlb_2026_nyy_bos_0401_1' + 'game_mlb_2026_20260401_nyy_bos_1' """ # Normalize sport and abbreviations sport_norm = sport.lower() away_norm = away_abbrev.lower() home_norm = home_abbrev.lower() - # Format date as MMDD + # Format date as YYYYMMDD if isinstance(game_date, datetime): game_date = game_date.date() - date_str = game_date.strftime("%m%d") + date_str = game_date.strftime("%Y%m%d") - # Build ID - parts = [sport_norm, str(season), away_norm, home_norm, date_str] + # Build ID with game_ prefix + parts = ["game", sport_norm, str(season), date_str, away_norm, home_norm] # Add game number for doubleheaders if game_number is not None: @@ -177,50 +177,55 @@ def parse_game_id(game_id: str) -> dict: """Parse a canonical game ID into its components. Args: - game_id: Canonical game ID (e.g., 'nba_2025_hou_okc_1021') + game_id: Canonical game ID (e.g., 'game_nba_2025_20251021_hou_okc') Returns: Dictionary with keys: sport, season, away_abbrev, home_abbrev, - month, day, game_number (optional) + year, month, day, game_number (optional) Raises: ValueError: If game_id format is invalid Examples: - >>> parse_game_id('nba_2025_hou_okc_1021') + >>> parse_game_id('game_nba_2025_20251021_hou_okc') {'sport': 'nba', 'season': 2025, 'away_abbrev': 'hou', - 'home_abbrev': 'okc', 'month': 10, 'day': 21, 'game_number': None} + 'home_abbrev': 'okc', 'year': 2025, 'month': 10, 'day': 21, 'game_number': None} - >>> parse_game_id('mlb_2026_nyy_bos_0401_1') + >>> parse_game_id('game_mlb_2026_20260401_nyy_bos_1') {'sport': 'mlb', 'season': 2026, 'away_abbrev': 'nyy', - 'home_abbrev': 'bos', 'month': 4, 'day': 1, 'game_number': 1} + 'home_abbrev': 'bos', 'year': 2026, 'month': 4, 'day': 1, 'game_number': 1} """ parts = game_id.split("_") - if len(parts) < 5 or len(parts) > 6: + if len(parts) < 6 or len(parts) > 7: raise ValueError(f"Invalid game ID format: {game_id}") - sport = parts[0] - season = int(parts[1]) - away_abbrev = parts[2] - home_abbrev = parts[3] - date_str = parts[4] + if parts[0] != "game": + raise ValueError(f"Game ID must start with 'game_': {game_id}") - if len(date_str) != 4: + sport = parts[1] + season = int(parts[2]) + date_str = parts[3] + away_abbrev = parts[4] + home_abbrev = parts[5] + + if len(date_str) != 8: raise ValueError(f"Invalid date format in game ID: {game_id}") - month = int(date_str[:2]) - day = int(date_str[2:]) + year = int(date_str[:4]) + month = int(date_str[4:6]) + day = int(date_str[6:]) game_number = None - if len(parts) == 6: - game_number = int(parts[5]) + if len(parts) == 7: + game_number = int(parts[6]) return { "sport": sport, "season": season, "away_abbrev": away_abbrev, "home_abbrev": home_abbrev, + "year": year, "month": month, "day": day, "game_number": game_number, diff --git a/Scripts/sportstime_parser/tests/test_canonical_id.py b/Scripts/sportstime_parser/tests/test_canonical_id.py index c1ae9e7..cf3951a 100644 --- a/Scripts/sportstime_parser/tests/test_canonical_id.py +++ b/Scripts/sportstime_parser/tests/test_canonical_id.py @@ -55,7 +55,7 @@ class TestGenerateGameId: home_abbrev="lal", game_date=date(2025, 12, 25), ) - assert game_id == "nba_2025_bos_lal_1225" + assert game_id == "game_nba_2025_20251225_bos_lal" def test_game_id_with_datetime(self): """Test game ID generation with datetime object.""" @@ -66,7 +66,7 @@ class TestGenerateGameId: home_abbrev="bos", game_date=datetime(2026, 4, 1, 19, 0), ) - assert game_id == "mlb_2026_nyy_bos_0401" + assert game_id == "game_mlb_2026_20260401_nyy_bos" def test_game_id_with_game_number(self): """Test game ID for doubleheader.""" @@ -86,8 +86,8 @@ class TestGenerateGameId: game_date=date(2026, 7, 4), game_number=2, ) - assert game_id_1 == "mlb_2026_nyy_bos_0704_1" - assert game_id_2 == "mlb_2026_nyy_bos_0704_2" + assert game_id_1 == "game_mlb_2026_20260704_nyy_bos_1" + assert game_id_2 == "game_mlb_2026_20260704_nyy_bos_2" def test_sport_lowercased(self): """Test that sport is lowercased.""" @@ -98,7 +98,7 @@ class TestGenerateGameId: home_abbrev="LAL", game_date=date(2025, 12, 25), ) - assert game_id == "nba_2025_bos_lal_1225" + assert game_id == "game_nba_2025_20251225_bos_lal" class TestParseGameId: @@ -106,22 +106,24 @@ class TestParseGameId: def test_parse_basic_game_id(self): """Test parsing a basic game ID.""" - parsed = parse_game_id("nba_2025_bos_lal_1225") + parsed = parse_game_id("game_nba_2025_20251225_bos_lal") assert parsed["sport"] == "nba" assert parsed["season"] == 2025 assert parsed["away_abbrev"] == "bos" assert parsed["home_abbrev"] == "lal" + assert parsed["year"] == 2025 assert parsed["month"] == 12 assert parsed["day"] == 25 assert parsed["game_number"] is None def test_parse_game_id_with_game_number(self): """Test parsing game ID with game number.""" - parsed = parse_game_id("mlb_2026_nyy_bos_0704_2") + parsed = parse_game_id("game_mlb_2026_20260704_nyy_bos_2") assert parsed["sport"] == "mlb" assert parsed["season"] == 2026 assert parsed["away_abbrev"] == "nyy" assert parsed["home_abbrev"] == "bos" + assert parsed["year"] == 2026 assert parsed["month"] == 7 assert parsed["day"] == 4 assert parsed["game_number"] == 2 @@ -131,9 +133,11 @@ class TestParseGameId: with pytest.raises(ValueError): parse_game_id("invalid") with pytest.raises(ValueError): - parse_game_id("nba_2025_bos") + parse_game_id("nba_2025_bos") # Missing game_ prefix with pytest.raises(ValueError): parse_game_id("") + with pytest.raises(ValueError): + parse_game_id("game_nba_2025_bos_lal") # Missing date class TestGenerateTeamId: diff --git a/SportsTime/Core/Models/Domain/Stadium.swift b/SportsTime/Core/Models/Domain/Stadium.swift index 0b83fe7..ca32ee2 100644 --- a/SportsTime/Core/Models/Domain/Stadium.swift +++ b/SportsTime/Core/Models/Domain/Stadium.swift @@ -31,6 +31,7 @@ struct Stadium: Identifiable, Codable, Hashable { let yearOpened: Int? let imageURL: URL? let timeZoneIdentifier: String? + let primaryTeamAbbreviations: [String] // Teams that play here (e.g., ["LAC", "LAR"] for SoFi Stadium) init( id: String, @@ -43,7 +44,8 @@ struct Stadium: Identifiable, Codable, Hashable { sport: Sport, yearOpened: Int? = nil, imageURL: URL? = nil, - timeZoneIdentifier: String? = nil + timeZoneIdentifier: String? = nil, + primaryTeamAbbreviations: [String] = [] ) { self.id = id self.name = name @@ -56,6 +58,7 @@ struct Stadium: Identifiable, Codable, Hashable { self.yearOpened = yearOpened self.imageURL = imageURL self.timeZoneIdentifier = timeZoneIdentifier + self.primaryTeamAbbreviations = primaryTeamAbbreviations } var timeZone: TimeZone? { diff --git a/SportsTime/Core/Models/Domain/Team.swift b/SportsTime/Core/Models/Domain/Team.swift index 023dd18..a53f3c4 100644 --- a/SportsTime/Core/Models/Domain/Team.swift +++ b/SportsTime/Core/Models/Domain/Team.swift @@ -23,6 +23,8 @@ struct Team: Identifiable, Codable, Hashable { let sport: Sport let city: String let stadiumId: String // FK: "stadium_mlb_fenway_park" + let conferenceId: String? // FK: "nba_eastern", "mlb_al" + let divisionId: String? // FK: "nba_southeast", "mlb_al_east" let logoURL: URL? let primaryColor: String? let secondaryColor: String? @@ -34,6 +36,8 @@ struct Team: Identifiable, Codable, Hashable { sport: Sport, city: String, stadiumId: String, + conferenceId: String? = nil, + divisionId: String? = nil, logoURL: URL? = nil, primaryColor: String? = nil, secondaryColor: String? = nil @@ -44,6 +48,8 @@ struct Team: Identifiable, Codable, Hashable { self.sport = sport self.city = city self.stadiumId = stadiumId + self.conferenceId = conferenceId + self.divisionId = divisionId self.logoURL = logoURL self.primaryColor = primaryColor self.secondaryColor = secondaryColor diff --git a/SportsTime/Core/Models/Local/CanonicalModels.swift b/SportsTime/Core/Models/Local/CanonicalModels.swift index 23d0b9e..ae2fa8d 100644 --- a/SportsTime/Core/Models/Local/CanonicalModels.swift +++ b/SportsTime/Core/Models/Local/CanonicalModels.swift @@ -320,6 +320,8 @@ final class CanonicalTeam { sport: sportEnum ?? .mlb, city: city, stadiumId: stadiumCanonicalId, + conferenceId: conferenceId, + divisionId: divisionId, logoURL: logoURL.flatMap { URL(string: $0) }, primaryColor: primaryColor, secondaryColor: secondaryColor diff --git a/SportsTime/Core/Services/BootstrapService.swift b/SportsTime/Core/Services/BootstrapService.swift index ffd6933..f108d4b 100644 --- a/SportsTime/Core/Services/BootstrapService.swift +++ b/SportsTime/Core/Services/BootstrapService.swift @@ -47,6 +47,7 @@ actor BootstrapService { let primary_team_abbrevs: [String] let year_opened: Int? let timezone_identifier: String? + let image_url: String? } private struct JSONCanonicalTeam: Codable { @@ -66,8 +67,9 @@ actor BootstrapService { let canonical_id: String let sport: String let season: String - let date: String - let time: String? + let game_datetime_utc: String? // ISO 8601 format (new canonical format) + let date: String? // Legacy format (deprecated) + let time: String? // Legacy format (deprecated) let home_team_canonical_id: String let away_team_canonical_id: String let stadium_canonical_id: String @@ -215,6 +217,7 @@ actor BootstrapService { longitude: jsonStadium.longitude, capacity: jsonStadium.capacity, yearOpened: jsonStadium.year_opened, + imageURL: jsonStadium.image_url, sport: jsonStadium.sport, timezoneIdentifier: jsonStadium.timezone_identifier ) @@ -423,10 +426,18 @@ actor BootstrapService { guard !seenGameIds.contains(jsonGame.canonical_id) else { continue } seenGameIds.insert(jsonGame.canonical_id) - guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else { - continue + // Parse datetime: prefer ISO 8601 format, fall back to legacy date+time + let dateTime: Date? + if let iso8601String = jsonGame.game_datetime_utc { + dateTime = parseISO8601(iso8601String) + } else if let date = jsonGame.date { + dateTime = parseDateTime(date: date, time: jsonGame.time ?? "7:00p") + } else { + dateTime = nil } + guard let dateTime else { continue } + let game = CanonicalGame( canonicalId: jsonGame.canonical_id, schemaVersion: SchemaVersion.current, @@ -677,6 +688,13 @@ actor BootstrapService { return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))" } + nonisolated private func parseISO8601(_ string: String) -> Date? { + // Handle ISO 8601 format: "2026-03-01T18:05:00Z" + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } + nonisolated private func parseDateTime(date: String, time: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX")