fix: resolve specificStadium achievement ID mismatch

The Green Monster (Fenway) and Ivy League (Wrigley) achievements
weren't working because:
1. Symbolic IDs use lowercase sport (stadium_mlb_bos)
2. Sport enum uses uppercase raw values (MLB)
3. Visits store stadium UUIDs, not symbolic IDs

Added resolveSymbolicStadiumId() helper that:
- Uppercases the sport string before Sport(rawValue:)
- Looks up team by abbreviation and sport
- Returns the team's stadiumId as UUID string

Also fixed:
- getStadiumIdsForLeague returns UUID strings (not symbolic IDs)
- AchievementProgress.isEarned computed from progress OR stored record
- getStadiumIdsForDivision queries CanonicalTeam properly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-11 22:22:29 -06:00
parent dcd5edb229
commit 5c13650742
20 changed files with 1619 additions and 141 deletions

View File

@@ -50,6 +50,10 @@ struct GameMatchCandidate: Identifiable, Sendable {
let awayTeam: Team
let confidence: PhotoMatchConfidence
// Scraped score components (stored separately for flexible formatting)
let scrapedHomeScore: Int?
let scrapedAwayScore: Int?
init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) {
self.id = game.id
self.game = game
@@ -57,6 +61,57 @@ struct GameMatchCandidate: Identifiable, Sendable {
self.homeTeam = homeTeam
self.awayTeam = awayTeam
self.confidence = confidence
self.scrapedHomeScore = nil
self.scrapedAwayScore = nil
}
/// Initialize from a scraped historical game
init(scrapedGame: ScrapedGame, stadium: Stadium) {
self.id = UUID()
self.stadium = stadium
// Create synthetic Team objects from scraped names
// Scraped names already include city (e.g., "Chicago Cubs"), so we use empty city
// to avoid duplication in fullName computed property
self.homeTeam = Team(
id: UUID(),
name: scrapedGame.homeTeam,
abbreviation: String(scrapedGame.homeTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
city: "",
stadiumId: stadium.id
)
self.awayTeam = Team(
id: UUID(),
name: scrapedGame.awayTeam,
abbreviation: String(scrapedGame.awayTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
city: "",
stadiumId: stadium.id
)
// Create synthetic Game object
let year = Calendar.current.component(.year, from: scrapedGame.date)
self.game = Game(
id: self.id,
homeTeamId: self.homeTeam.id,
awayTeamId: self.awayTeam.id,
stadiumId: stadium.id,
dateTime: scrapedGame.date,
sport: scrapedGame.sport,
season: "\(year)"
)
// High confidence since we found the exact game from web
self.confidence = PhotoMatchConfidence(
spatial: .high,
temporal: .exactDay
)
// Store scraped scores separately for flexible formatting
self.scrapedHomeScore = scrapedGame.homeScore
self.scrapedAwayScore = scrapedGame.awayScore
}
var matchupDescription: String {
@@ -67,6 +122,18 @@ struct GameMatchCandidate: Identifiable, Sendable {
"\(awayTeam.fullName) at \(homeTeam.fullName)"
}
/// Final score in "away-home" format for storage (e.g., "5-3")
var formattedFinalScore: String? {
guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil }
return "\(away)-\(home)"
}
/// Score for display with team names (e.g., "Pirates 5\nCubs 3")
var displayScore: String? {
guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil }
return "\(awayTeam.fullName) \(away)\n\(homeTeam.fullName) \(home)"
}
var gameDateTime: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@@ -220,6 +287,14 @@ final class GameMatcher {
metadata: PhotoMetadata,
sport: Sport? = nil
) async -> PhotoImportCandidate {
print("🎯 [GameMatcher] Processing photo for import")
print("🎯 [GameMatcher] - hasValidDate: \(metadata.hasValidDate)")
print("🎯 [GameMatcher] - captureDate: \(metadata.captureDate?.description ?? "nil")")
print("🎯 [GameMatcher] - hasValidLocation: \(metadata.hasValidLocation)")
if let coords = metadata.coordinates {
print("🎯 [GameMatcher] - coordinates: \(coords.latitude), \(coords.longitude)")
}
// Get stadium matches regardless of game matching
var stadiumMatches: [StadiumMatch] = []
if let coordinates = metadata.coordinates {
@@ -227,9 +302,53 @@ final class GameMatcher {
coordinates: coordinates,
sport: sport
)
print("🎯 [GameMatcher] - nearby stadiums found: \(stadiumMatches.count)")
for match in stadiumMatches.prefix(3) {
print("🎯 [GameMatcher] • \(match.stadium.name) (\(String(format: "%.1f", match.distance / 1609.34)) mi)")
}
} else {
print("🎯 [GameMatcher] - no coordinates, skipping stadium proximity search")
}
let matchResult = await matchGame(metadata: metadata, sport: sport)
var matchResult = await matchGame(metadata: metadata, sport: sport)
switch matchResult {
case .singleMatch(let match):
print("🎯 [GameMatcher] - result: singleMatch - \(match.homeTeam.fullName) vs \(match.awayTeam.fullName)")
case .multipleMatches(let matches):
print("🎯 [GameMatcher] - result: multipleMatches (\(matches.count) games)")
case .noMatches(let reason):
print("🎯 [GameMatcher] - result: noMatches - \(reason)")
// FALLBACK: Try scraping historical game data if we have stadium + date
// Try all nearby stadiums (important for multi-sport venues like American Airlines Center)
if let captureDate = metadata.captureDate, !stadiumMatches.isEmpty {
print("🎯 [GameMatcher] - Trying historical scraper fallback...")
for stadiumMatch in stadiumMatches {
let stadium = stadiumMatch.stadium
print("🎯 [GameMatcher] - Trying \(stadium.name) (\(stadium.sport.rawValue))...")
if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame(
stadium: stadium,
date: captureDate
) {
print("🎯 [GameMatcher] - ✅ Scraper found: \(scrapedGame.awayTeam) @ \(scrapedGame.homeTeam)")
// Convert ScrapedGame to a match result
matchResult = .singleMatch(GameMatchCandidate(
scrapedGame: scrapedGame,
stadium: stadium
))
break // Found a game, stop searching
}
}
if case .noMatches = matchResult {
print("🎯 [GameMatcher] - Scraper found no game at any stadium")
}
}
}
return PhotoImportCandidate(
metadata: metadata,