This commit is contained in:
Trey t
2026-01-19 23:53:37 -06:00
parent 19dd1791f1
commit 11adfc10dd
8 changed files with 126 additions and 42 deletions

View File

@@ -82,6 +82,7 @@ class Stadium:
"primary_team_abbrevs": primary_team_abbrevs or [], "primary_team_abbrevs": primary_team_abbrevs or [],
"year_opened": self.opened_year, "year_opened": self.opened_year,
"timezone_identifier": self.timezone, "timezone_identifier": self.timezone,
"image_url": self.image_url,
} }
@classmethod @classmethod
@@ -118,6 +119,8 @@ class Stadium:
longitude=data["longitude"], longitude=data["longitude"],
capacity=data.get("capacity"), capacity=data.get("capacity"),
opened_year=data.get("year_opened"), opened_year=data.get("year_opened"),
image_url=data.get("image_url"),
timezone=data.get("timezone_identifier"),
) )
def to_json(self) -> str: def to_json(self) -> str:

View File

@@ -54,6 +54,20 @@ class Team:
"stadium_id": self.stadium_id, "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: def to_canonical_dict(self) -> dict:
"""Convert to canonical dictionary format matching iOS app schema. """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") "sport": self.sport.upper(), # iOS Sport enum expects uppercase (e.g., "NFL")
"city": self.city, "city": self.city,
"stadium_canonical_id": self.stadium_id or "", "stadium_canonical_id": self.stadium_id or "",
"conference_id": self.conference, "conference_id": self._make_qualified_id(self.conference),
"division_id": self.division, "division_id": self._make_qualified_id(self.division),
"primary_color": self.primary_color, "primary_color": self.primary_color,
"secondary_color": self.secondary_color, "secondary_color": self.secondary_color,
} }
@@ -91,9 +105,38 @@ class Team:
stadium_id=data.get("stadium_id"), 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 @classmethod
def from_canonical_dict(cls, data: dict) -> "Team": def from_canonical_dict(cls, data: dict) -> "Team":
"""Create a Team from a canonical dictionary (iOS app format).""" """Create a Team from a canonical dictionary (iOS app format)."""
sport = data["sport"].lower()
return cls( return cls(
id=data["canonical_id"], id=data["canonical_id"],
sport=data["sport"], sport=data["sport"],
@@ -101,8 +144,8 @@ class Team:
name=data["name"], name=data["name"],
full_name=f"{data['city']} {data['name']}", # Reconstruct full_name full_name=f"{data['city']} {data['name']}", # Reconstruct full_name
abbreviation=data["abbreviation"], abbreviation=data["abbreviation"],
conference=data.get("conference_id"), conference=cls._extract_name_from_qualified_id(data.get("conference_id"), sport),
division=data.get("division_id"), division=cls._extract_name_from_qualified_id(data.get("division_id"), sport),
primary_color=data.get("primary_color"), primary_color=data.get("primary_color"),
secondary_color=data.get("secondary_color"), secondary_color=data.get("secondary_color"),
stadium_id=data.get("stadium_canonical_id"), stadium_id=data.get("stadium_canonical_id"),

View File

@@ -53,7 +53,7 @@ def generate_game_id(
) -> str: ) -> str:
"""Generate a canonical game ID. """Generate a canonical game ID.
Format: {sport}_{season}_{away}_{home}_{MMDD}[_{game_number}] Format: game_{sport}_{season}_{YYYYMMDD}_{away}_{home}[_{game_number}]
Args: Args:
sport: Sport code (e.g., 'nba', 'mlb') 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 game_number: Game number for doubleheaders (1 or 2), None for single games
Returns: Returns:
Canonical game ID (e.g., 'nba_2025_hou_okc_1021') Canonical game ID (e.g., 'game_nba_2025_20251021_hou_okc')
Examples: Examples:
>>> generate_game_id('nba', 2025, 'HOU', 'OKC', date(2025, 10, 21)) >>> 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) >>> 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 # Normalize sport and abbreviations
sport_norm = sport.lower() sport_norm = sport.lower()
away_norm = away_abbrev.lower() away_norm = away_abbrev.lower()
home_norm = home_abbrev.lower() home_norm = home_abbrev.lower()
# Format date as MMDD # Format date as YYYYMMDD
if isinstance(game_date, datetime): if isinstance(game_date, datetime):
game_date = game_date.date() game_date = game_date.date()
date_str = game_date.strftime("%m%d") date_str = game_date.strftime("%Y%m%d")
# Build ID # Build ID with game_ prefix
parts = [sport_norm, str(season), away_norm, home_norm, date_str] parts = ["game", sport_norm, str(season), date_str, away_norm, home_norm]
# Add game number for doubleheaders # Add game number for doubleheaders
if game_number is not None: 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. """Parse a canonical game ID into its components.
Args: 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: Returns:
Dictionary with keys: sport, season, away_abbrev, home_abbrev, Dictionary with keys: sport, season, away_abbrev, home_abbrev,
month, day, game_number (optional) year, month, day, game_number (optional)
Raises: Raises:
ValueError: If game_id format is invalid ValueError: If game_id format is invalid
Examples: 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', {'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', {'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("_") 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}") raise ValueError(f"Invalid game ID format: {game_id}")
sport = parts[0] if parts[0] != "game":
season = int(parts[1]) raise ValueError(f"Game ID must start with 'game_': {game_id}")
away_abbrev = parts[2]
home_abbrev = parts[3]
date_str = parts[4]
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}") raise ValueError(f"Invalid date format in game ID: {game_id}")
month = int(date_str[:2]) year = int(date_str[:4])
day = int(date_str[2:]) month = int(date_str[4:6])
day = int(date_str[6:])
game_number = None game_number = None
if len(parts) == 6: if len(parts) == 7:
game_number = int(parts[5]) game_number = int(parts[6])
return { return {
"sport": sport, "sport": sport,
"season": season, "season": season,
"away_abbrev": away_abbrev, "away_abbrev": away_abbrev,
"home_abbrev": home_abbrev, "home_abbrev": home_abbrev,
"year": year,
"month": month, "month": month,
"day": day, "day": day,
"game_number": game_number, "game_number": game_number,

View File

@@ -55,7 +55,7 @@ class TestGenerateGameId:
home_abbrev="lal", home_abbrev="lal",
game_date=date(2025, 12, 25), 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): def test_game_id_with_datetime(self):
"""Test game ID generation with datetime object.""" """Test game ID generation with datetime object."""
@@ -66,7 +66,7 @@ class TestGenerateGameId:
home_abbrev="bos", home_abbrev="bos",
game_date=datetime(2026, 4, 1, 19, 0), 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): def test_game_id_with_game_number(self):
"""Test game ID for doubleheader.""" """Test game ID for doubleheader."""
@@ -86,8 +86,8 @@ class TestGenerateGameId:
game_date=date(2026, 7, 4), game_date=date(2026, 7, 4),
game_number=2, game_number=2,
) )
assert game_id_1 == "mlb_2026_nyy_bos_0704_1" assert game_id_1 == "game_mlb_2026_20260704_nyy_bos_1"
assert game_id_2 == "mlb_2026_nyy_bos_0704_2" assert game_id_2 == "game_mlb_2026_20260704_nyy_bos_2"
def test_sport_lowercased(self): def test_sport_lowercased(self):
"""Test that sport is lowercased.""" """Test that sport is lowercased."""
@@ -98,7 +98,7 @@ class TestGenerateGameId:
home_abbrev="LAL", home_abbrev="LAL",
game_date=date(2025, 12, 25), 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: class TestParseGameId:
@@ -106,22 +106,24 @@ class TestParseGameId:
def test_parse_basic_game_id(self): def test_parse_basic_game_id(self):
"""Test parsing a basic game ID.""" """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["sport"] == "nba"
assert parsed["season"] == 2025 assert parsed["season"] == 2025
assert parsed["away_abbrev"] == "bos" assert parsed["away_abbrev"] == "bos"
assert parsed["home_abbrev"] == "lal" assert parsed["home_abbrev"] == "lal"
assert parsed["year"] == 2025
assert parsed["month"] == 12 assert parsed["month"] == 12
assert parsed["day"] == 25 assert parsed["day"] == 25
assert parsed["game_number"] is None assert parsed["game_number"] is None
def test_parse_game_id_with_game_number(self): def test_parse_game_id_with_game_number(self):
"""Test parsing game ID with game number.""" """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["sport"] == "mlb"
assert parsed["season"] == 2026 assert parsed["season"] == 2026
assert parsed["away_abbrev"] == "nyy" assert parsed["away_abbrev"] == "nyy"
assert parsed["home_abbrev"] == "bos" assert parsed["home_abbrev"] == "bos"
assert parsed["year"] == 2026
assert parsed["month"] == 7 assert parsed["month"] == 7
assert parsed["day"] == 4 assert parsed["day"] == 4
assert parsed["game_number"] == 2 assert parsed["game_number"] == 2
@@ -131,9 +133,11 @@ class TestParseGameId:
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_game_id("invalid") parse_game_id("invalid")
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_game_id("nba_2025_bos") parse_game_id("nba_2025_bos") # Missing game_ prefix
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_game_id("") parse_game_id("")
with pytest.raises(ValueError):
parse_game_id("game_nba_2025_bos_lal") # Missing date
class TestGenerateTeamId: class TestGenerateTeamId:

View File

@@ -31,6 +31,7 @@ struct Stadium: Identifiable, Codable, Hashable {
let yearOpened: Int? let yearOpened: Int?
let imageURL: URL? let imageURL: URL?
let timeZoneIdentifier: String? let timeZoneIdentifier: String?
let primaryTeamAbbreviations: [String] // Teams that play here (e.g., ["LAC", "LAR"] for SoFi Stadium)
init( init(
id: String, id: String,
@@ -43,7 +44,8 @@ struct Stadium: Identifiable, Codable, Hashable {
sport: Sport, sport: Sport,
yearOpened: Int? = nil, yearOpened: Int? = nil,
imageURL: URL? = nil, imageURL: URL? = nil,
timeZoneIdentifier: String? = nil timeZoneIdentifier: String? = nil,
primaryTeamAbbreviations: [String] = []
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -56,6 +58,7 @@ struct Stadium: Identifiable, Codable, Hashable {
self.yearOpened = yearOpened self.yearOpened = yearOpened
self.imageURL = imageURL self.imageURL = imageURL
self.timeZoneIdentifier = timeZoneIdentifier self.timeZoneIdentifier = timeZoneIdentifier
self.primaryTeamAbbreviations = primaryTeamAbbreviations
} }
var timeZone: TimeZone? { var timeZone: TimeZone? {

View File

@@ -23,6 +23,8 @@ struct Team: Identifiable, Codable, Hashable {
let sport: Sport let sport: Sport
let city: String let city: String
let stadiumId: String // FK: "stadium_mlb_fenway_park" 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 logoURL: URL?
let primaryColor: String? let primaryColor: String?
let secondaryColor: String? let secondaryColor: String?
@@ -34,6 +36,8 @@ struct Team: Identifiable, Codable, Hashable {
sport: Sport, sport: Sport,
city: String, city: String,
stadiumId: String, stadiumId: String,
conferenceId: String? = nil,
divisionId: String? = nil,
logoURL: URL? = nil, logoURL: URL? = nil,
primaryColor: String? = nil, primaryColor: String? = nil,
secondaryColor: String? = nil secondaryColor: String? = nil
@@ -44,6 +48,8 @@ struct Team: Identifiable, Codable, Hashable {
self.sport = sport self.sport = sport
self.city = city self.city = city
self.stadiumId = stadiumId self.stadiumId = stadiumId
self.conferenceId = conferenceId
self.divisionId = divisionId
self.logoURL = logoURL self.logoURL = logoURL
self.primaryColor = primaryColor self.primaryColor = primaryColor
self.secondaryColor = secondaryColor self.secondaryColor = secondaryColor

View File

@@ -320,6 +320,8 @@ final class CanonicalTeam {
sport: sportEnum ?? .mlb, sport: sportEnum ?? .mlb,
city: city, city: city,
stadiumId: stadiumCanonicalId, stadiumId: stadiumCanonicalId,
conferenceId: conferenceId,
divisionId: divisionId,
logoURL: logoURL.flatMap { URL(string: $0) }, logoURL: logoURL.flatMap { URL(string: $0) },
primaryColor: primaryColor, primaryColor: primaryColor,
secondaryColor: secondaryColor secondaryColor: secondaryColor

View File

@@ -47,6 +47,7 @@ actor BootstrapService {
let primary_team_abbrevs: [String] let primary_team_abbrevs: [String]
let year_opened: Int? let year_opened: Int?
let timezone_identifier: String? let timezone_identifier: String?
let image_url: String?
} }
private struct JSONCanonicalTeam: Codable { private struct JSONCanonicalTeam: Codable {
@@ -66,8 +67,9 @@ actor BootstrapService {
let canonical_id: String let canonical_id: String
let sport: String let sport: String
let season: String let season: String
let date: String let game_datetime_utc: String? // ISO 8601 format (new canonical format)
let time: String? let date: String? // Legacy format (deprecated)
let time: String? // Legacy format (deprecated)
let home_team_canonical_id: String let home_team_canonical_id: String
let away_team_canonical_id: String let away_team_canonical_id: String
let stadium_canonical_id: String let stadium_canonical_id: String
@@ -215,6 +217,7 @@ actor BootstrapService {
longitude: jsonStadium.longitude, longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity, capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened, yearOpened: jsonStadium.year_opened,
imageURL: jsonStadium.image_url,
sport: jsonStadium.sport, sport: jsonStadium.sport,
timezoneIdentifier: jsonStadium.timezone_identifier timezoneIdentifier: jsonStadium.timezone_identifier
) )
@@ -423,10 +426,18 @@ actor BootstrapService {
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue } guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
seenGameIds.insert(jsonGame.canonical_id) seenGameIds.insert(jsonGame.canonical_id)
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else { // Parse datetime: prefer ISO 8601 format, fall back to legacy date+time
continue 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( let game = CanonicalGame(
canonicalId: jsonGame.canonical_id, canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current, schemaVersion: SchemaVersion.current,
@@ -677,6 +688,13 @@ actor BootstrapService {
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))" 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? { nonisolated private func parseDateTime(date: String, time: String) -> Date? {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")