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

@@ -118,11 +118,18 @@ struct VisitSummary: Identifiable {
let visitDate: Date
let visitType: VisitType
let sport: Sport
let matchup: String?
let homeTeamName: String?
let awayTeamName: String?
let score: String?
let photoCount: Int
let notes: String?
/// Combined matchup for backwards compatibility
var matchup: String? {
guard let home = homeTeamName, let away = awayTeamName else { return nil }
return "\(away) @ \(home)"
}
var dateDescription: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium

View File

@@ -39,7 +39,7 @@ struct Team: Identifiable, Codable, Hashable {
}
var fullName: String {
"\(city) \(name)"
city.isEmpty ? name : "\(city) \(name)"
}
}

View File

@@ -165,14 +165,14 @@ final class AchievementEngine {
visitedStadiumIds: visitedStadiumIds
)
let isEarned = earnedIds.contains(definition.id)
let hasStoredAchievement = earnedIds.contains(definition.id)
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
progress.append(AchievementProgress(
definition: definition,
currentProgress: current,
totalRequired: total,
isEarned: isEarned,
hasStoredAchievement: hasStoredAchievement,
earnedAt: earnedAt
))
}
@@ -214,11 +214,41 @@ final class AchievementEngine {
case .multipleLeagues(let leagueCount):
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
case .specificStadium(let stadiumId):
return visitedStadiumIds.contains(stadiumId)
case .specificStadium(let symbolicId):
// Resolve symbolic ID (e.g., "stadium_mlb_bos") to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return false }
return visitedStadiumIds.contains(resolvedId)
}
}
/// Resolves symbolic stadium IDs (e.g., "stadium_mlb_bos") to actual stadium UUID strings
private func resolveSymbolicStadiumId(_ symbolicId: String) -> String? {
// Parse symbolic ID format: "stadium_{sport}_{teamAbbrev}"
let parts = symbolicId.split(separator: "_")
guard parts.count == 3,
parts[0] == "stadium" else {
return nil
}
// Sport raw values are uppercase (e.g., "MLB"), but symbolic IDs use lowercase
let sportString = String(parts[1]).uppercased()
guard let sport = Sport(rawValue: sportString) else {
return nil
}
let teamAbbrev = String(parts[2]).uppercased()
// Find team by abbreviation and sport
guard let team = dataProvider.teams.first(where: {
$0.abbreviation.uppercased() == teamAbbrev && $0.sport == sport
}) else {
return nil
}
// Return the stadium UUID as string (matches visit's canonicalStadiumId format)
return team.stadiumId.uuidString
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
@@ -318,8 +348,10 @@ final class AchievementEngine {
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return (leagues.count, leagueCount)
case .specificStadium(let stadiumId):
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1)
case .specificStadium(let symbolicId):
// Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return (0, 1) }
return (visitedStadiumIds.contains(resolvedId) ? 1 : 0, 1)
}
}
@@ -359,25 +391,36 @@ final class AchievementEngine {
}
return []
case .specificStadium(let stadiumId):
return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id }
case .specificStadium(let symbolicId):
// Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return [] }
return visits.filter { $0.canonicalStadiumId == resolvedId }.map { $0.id }
}
}
// MARK: - Stadium Lookups
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
// Get teams in division, then their stadiums
let teams = dataProvider.teams.filter { team in
// Match division by checking team's division assignment
// This would normally come from CanonicalTeam.divisionId
// For now, return based on division structure
return false // Will be populated when division data is linked
// Query CanonicalTeam to find teams in this division
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.divisionId == divisionId && $0.deprecatedAt == nil }
)
guard let canonicalTeams = try? modelContext.fetch(descriptor) else {
return []
}
// For now, return hardcoded counts based on typical division sizes
// This should be replaced with actual team-to-stadium mapping
return []
// Get stadium UUIDs for these teams
// CanonicalTeam has stadiumCanonicalId, we need to find the corresponding Stadium UUID
var stadiumIds: [String] = []
for canonicalTeam in canonicalTeams {
// Find the domain team by matching name/abbreviation to get stadium UUID
if let team = dataProvider.teams.first(where: { $0.abbreviation == canonicalTeam.abbreviation && $0.sport.rawValue == canonicalTeam.sport }) {
stadiumIds.append(team.stadiumId.uuidString)
}
}
return stadiumIds
}
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
@@ -391,7 +434,7 @@ final class AchievementEngine {
}
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
// Get all stadiums for this sport
// Get all stadiums for this sport - return UUID strings to match visit format
return dataProvider.stadiums
.filter { stadium in
// Check if stadium hosts teams of this sport
@@ -399,7 +442,7 @@ final class AchievementEngine {
team.stadiumId == stadium.id && team.sport == sport
}
}
.map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" }
.map { $0.id.uuidString }
}
// MARK: - Data Fetching
@@ -425,11 +468,17 @@ struct AchievementProgress: Identifiable {
let definition: AchievementDefinition
let currentProgress: Int
let totalRequired: Int
let isEarned: Bool
let hasStoredAchievement: Bool // Whether an Achievement record exists in SwiftData
let earnedAt: Date?
var id: String { definition.id }
/// Whether the achievement is earned (either stored or computed from progress)
var isEarned: Bool {
// Earned if we have a stored record OR if progress is complete
hasStoredAchievement || (totalRequired > 0 && currentProgress >= totalRequired)
}
var progressPercentage: Double {
guard totalRequired > 0 else { return 0 }
return Double(currentProgress) / Double(totalRequired)

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,

View File

@@ -0,0 +1,309 @@
//
// HistoricalGameScraper.swift
// SportsTime
//
// Scrapes historical game data from sports reference sites when local database has no match.
// Runs on-device so scales to unlimited users with no server costs.
//
import Foundation
import SwiftSoup
// MARK: - Scraped Game Result
struct ScrapedGame: Sendable {
let date: Date
let homeTeam: String
let awayTeam: String
let homeScore: Int?
let awayScore: Int?
let stadiumName: String
let sport: Sport
var formattedScore: String? {
guard let home = homeScore, let away = awayScore else { return nil }
return "\(awayTeam) \(away) - \(homeTeam) \(home)"
}
}
// MARK: - Historical Game Scraper
actor HistoricalGameScraper {
static let shared = HistoricalGameScraper()
// In-memory cache to avoid redundant network requests during a session
private var cache: [String: ScrapedGame?] = [:]
private init() {}
// MARK: - Public API
/// Scrape historical game data for a stadium on a specific date
func scrapeGame(stadium: Stadium, date: Date) async -> ScrapedGame? {
let cacheKey = "\(stadium.id)-\(dateKey(date))"
// Check cache first
if let cached = cache[cacheKey] {
print("🌐 [Scraper] Cache hit for \(stadium.name) on \(dateKey(date))")
return cached
}
print("🌐 [Scraper] ════════════════════════════════════════")
print("🌐 [Scraper] Scraping game for \(stadium.name) on \(dateKey(date))")
print("🌐 [Scraper] Sport: \(stadium.sport.rawValue)")
let result: ScrapedGame?
switch stadium.sport {
case .mlb:
result = await scrapeBaseballReference(stadium: stadium, date: date)
case .nba:
result = await scrapeBasketballReference(stadium: stadium, date: date)
case .nhl:
result = await scrapeHockeyReference(stadium: stadium, date: date)
case .nfl:
result = await scrapeFootballReference(stadium: stadium, date: date)
case .mls, .wnba, .nwsl:
print("🌐 [Scraper] ⚠️ \(stadium.sport.rawValue) scraping not yet implemented")
result = nil
}
cache[cacheKey] = result
if let game = result {
print("🌐 [Scraper] ✅ Found: \(game.awayTeam) @ \(game.homeTeam)")
if let score = game.formattedScore {
print("🌐 [Scraper] Score: \(score)")
}
} else {
print("🌐 [Scraper] ❌ No game found")
}
print("🌐 [Scraper] ════════════════════════════════════════")
return result
}
// MARK: - Sport-Specific Scrapers
private func scrapeBaseballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.baseball-reference.com/boxes/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .mlb)
}
private func scrapeBasketballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.basketball-reference.com/boxscores/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .nba)
}
private func scrapeHockeyReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.hockey-reference.com/boxscores/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .nhl)
}
private func scrapeFootballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
print("🌐 [Scraper] ⚠️ NFL scraping not yet implemented")
return nil
}
// MARK: - Generic Scraper (SwiftSoup)
private func scrapeReferencesSite(
urlString: String,
stadium: Stadium,
date: Date,
sport: Sport
) async -> ScrapedGame? {
print("🌐 [Scraper] Fetching: \(urlString)")
guard let url = URL(string: urlString) else { return nil }
do {
var request = URLRequest(url: url)
request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let html = String(data: data, encoding: .utf8) else {
print("🌐 [Scraper] ❌ Failed to fetch page")
return nil
}
print("🌐 [Scraper] Parsing HTML (\(data.count) bytes)")
let targetTeams = findTeamNamesForStadium(stadium)
guard !targetTeams.isEmpty else {
print("🌐 [Scraper] ❌ No team mapping for stadium: \(stadium.name)")
return nil
}
print("🌐 [Scraper] Looking for home team: \(targetTeams.first ?? "unknown")")
// Parse with SwiftSoup
let doc = try SwiftSoup.parse(html)
let gameSummaries = try doc.select("div.game_summary")
print("🌐 [Scraper] Found \(gameSummaries.count) games on this date")
for game in gameSummaries {
let rows = try game.select("table.teams tbody tr")
if rows.count >= 2 {
let awayRow = rows[0]
let homeRow = rows[1]
let awayTeam = try awayRow.select("td a").first()?.text() ?? ""
let homeTeam = try homeRow.select("td a").first()?.text() ?? ""
let awayScoreText = try awayRow.select("td.right").first()?.text() ?? ""
let homeScoreText = try homeRow.select("td.right").first()?.text() ?? ""
print("🌐 [Scraper] Game: \(awayTeam) @ \(homeTeam)")
// Check if any of our target team names match the home team
let isMatch = targetTeams.contains { targetName in
homeTeam.lowercased().contains(targetName.lowercased()) ||
targetName.lowercased().contains(homeTeam.lowercased())
}
if isMatch {
return ScrapedGame(
date: date,
homeTeam: homeTeam,
awayTeam: awayTeam,
homeScore: Int(homeScoreText),
awayScore: Int(awayScoreText),
stadiumName: stadium.name,
sport: sport
)
}
}
}
print("🌐 [Scraper] ⚠️ No game found for team: \(targetTeams.first ?? "unknown")")
} catch {
print("🌐 [Scraper] ❌ Error: \(error.localizedDescription)")
}
return nil
}
// MARK: - Helpers
private func dateComponents(_ date: Date) -> (Int, Int, Int) {
let calendar = Calendar.current
return (
calendar.component(.month, from: date),
calendar.component(.day, from: date),
calendar.component(.year, from: date)
)
}
private func dateKey(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
/// Returns an array of possible names for the home team (team name + city variations)
private func findTeamNamesForStadium(_ stadium: Stadium) -> [String] {
// Map stadium name to array of possible names (team name, city, abbreviations)
// Sports-Reference sites often use city names instead of team names
let stadiumTeamMap: [String: [String]] = [
// MLB
"Chase Field": ["Diamondbacks", "Arizona", "ARI"],
"Truist Park": ["Braves", "Atlanta", "ATL"],
"Oriole Park at Camden Yards": ["Orioles", "Baltimore", "BAL"],
"Fenway Park": ["Red Sox", "Boston", "BOS"],
"Wrigley Field": ["Cubs", "Chicago", "CHC"],
"Guaranteed Rate Field": ["White Sox", "Chicago", "CHW"],
"Great American Ball Park": ["Reds", "Cincinnati", "CIN"],
"Progressive Field": ["Guardians", "Cleveland", "Indians", "CLE"],
"Coors Field": ["Rockies", "Colorado", "COL"],
"Comerica Park": ["Tigers", "Detroit", "DET"],
"Minute Maid Park": ["Astros", "Houston", "HOU"],
"Kauffman Stadium": ["Royals", "Kansas City", "KCR"],
"Angel Stadium": ["Angels", "LA Angels", "Los Angeles", "Anaheim", "LAA"],
"Dodger Stadium": ["Dodgers", "LA Dodgers", "Los Angeles", "LAD"],
"loanDepot park": ["Marlins", "Miami", "Florida", "MIA"],
"American Family Field": ["Brewers", "Milwaukee", "MIL"],
"Target Field": ["Twins", "Minnesota", "MIN"],
"Citi Field": ["Mets", "NY Mets", "New York", "NYM"],
"Yankee Stadium": ["Yankees", "NY Yankees", "New York", "NYY"],
"Oakland Coliseum": ["Athletics", "Oakland", "OAK"],
"Citizens Bank Park": ["Phillies", "Philadelphia", "PHI"],
"PNC Park": ["Pirates", "Pittsburgh", "PIT"],
"Petco Park": ["Padres", "San Diego", "SDP"],
"Oracle Park": ["Giants", "San Francisco", "SF Giants", "SFG"],
"T-Mobile Park": ["Mariners", "Seattle", "SEA"],
"Busch Stadium": ["Cardinals", "St. Louis", "STL"],
"Tropicana Field": ["Rays", "Tampa Bay", "TBR"],
"Globe Life Field": ["Rangers", "Texas", "TEX"],
"Rogers Centre": ["Blue Jays", "Toronto", "TOR"],
"Nationals Park": ["Nationals", "Washington", "WSN"],
// NBA
"Footprint Center": ["Suns", "Phoenix", "PHX"],
"State Farm Arena": ["Hawks", "Atlanta", "ATL"],
"TD Garden": ["Celtics", "Boston", "BOS"],
"Barclays Center": ["Nets", "Brooklyn", "BKN"],
"Spectrum Center": ["Hornets", "Charlotte", "CHA"],
"United Center": ["Bulls", "Chicago", "CHI"],
"Rocket Mortgage FieldHouse": ["Cavaliers", "Cavs", "Cleveland", "CLE"],
"American Airlines Center": ["Mavericks", "Mavs", "Dallas", "DAL"],
"Ball Arena": ["Nuggets", "Denver", "DEN"],
"Little Caesars Arena": ["Pistons", "Detroit", "DET"],
"Chase Center": ["Warriors", "Golden State", "GSW"],
"Toyota Center": ["Rockets", "Houston", "HOU"],
"Gainbridge Fieldhouse": ["Pacers", "Indiana", "IND"],
"Crypto.com Arena": ["Lakers", "LA Lakers", "Los Angeles", "LAL"],
"FedExForum": ["Grizzlies", "Memphis", "MEM"],
"Kaseya Center": ["Heat", "Miami", "MIA"],
"Fiserv Forum": ["Bucks", "Milwaukee", "MIL"],
"Target Center": ["Timberwolves", "Wolves", "Minnesota", "MIN"],
"Smoothie King Center": ["Pelicans", "New Orleans", "NOP"],
"Madison Square Garden": ["Knicks", "NY Knicks", "New York", "NYK"],
"Paycom Center": ["Thunder", "Oklahoma City", "OKC"],
"Amway Center": ["Magic", "Orlando", "ORL"],
"Wells Fargo Center": ["76ers", "Sixers", "Philadelphia", "PHI"],
"Moda Center": ["Trail Blazers", "Blazers", "Portland", "POR"],
"Golden 1 Center": ["Kings", "Sacramento", "SAC"],
"AT&T Center": ["Spurs", "San Antonio", "SAS"],
"Scotiabank Arena": ["Raptors", "Toronto", "TOR"],
"Delta Center": ["Jazz", "Utah", "UTA"],
"Capital One Arena": ["Wizards", "Washington", "WAS"],
// NHL
"Mullett Arena": ["Coyotes", "Arizona", "ARI"],
"Climate Pledge Arena": ["Kraken", "Seattle", "SEA"],
"Prudential Center": ["Devils", "New Jersey", "NJD"],
"UBS Arena": ["Islanders", "NY Islanders", "New York", "NYI"],
"PPG Paints Arena": ["Penguins", "Pens", "Pittsburgh", "PIT"],
"Amerant Bank Arena": ["Panthers", "Florida", "FLA"],
"Amalie Arena": ["Lightning", "Tampa Bay", "TBL"],
"Enterprise Center": ["Blues", "St. Louis", "STL"],
"Canada Life Centre": ["Jets", "Winnipeg", "WPG"],
"Rogers Place": ["Oilers", "Edmonton", "EDM"],
"Scotiabank Saddledome": ["Flames", "Calgary", "CGY"],
"Rogers Arena": ["Canucks", "Vancouver", "VAN"],
"SAP Center": ["Sharks", "San Jose", "SJS"],
"Honda Center": ["Ducks", "Anaheim", "ANA"],
"T-Mobile Arena": ["Golden Knights", "Vegas", "Las Vegas", "VGK"],
"Xcel Energy Center": ["Wild", "Minnesota", "MIN"],
"Bridgestone Arena": ["Predators", "Preds", "Nashville", "NSH"],
"PNC Arena": ["Hurricanes", "Canes", "Carolina", "CAR"],
"Nationwide Arena": ["Blue Jackets", "Columbus", "CBJ"],
"KeyBank Center": ["Sabres", "Buffalo", "BUF"],
"Bell Centre": ["Canadiens", "Habs", "Montreal", "MTL"],
"Canadian Tire Centre": ["Senators", "Sens", "Ottawa", "OTT"],
]
return stadiumTeamMap[stadium.name] ?? []
}
func clearCache() {
cache.removeAll()
}
}

View File

@@ -43,18 +43,42 @@ actor PhotoMetadataExtractor {
/// Extract metadata from PHAsset (preferred method)
/// Uses PHAsset's location and creationDate properties
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata {
print("📸 [PhotoMetadata] ════════════════════════════════════════")
print("📸 [PhotoMetadata] Extracting from PHAsset: \(asset.localIdentifier)")
print("📸 [PhotoMetadata] Asset mediaType: \(asset.mediaType.rawValue) (1=image, 2=video)")
print("📸 [PhotoMetadata] Asset creationDate: \(asset.creationDate?.description ?? "nil")")
print("📸 [PhotoMetadata] Asset modificationDate: \(asset.modificationDate?.description ?? "nil")")
print("📸 [PhotoMetadata] Asset location: \(asset.location?.description ?? "nil")")
// PHAsset provides location and date directly
let coordinates: CLLocationCoordinate2D?
if let location = asset.location {
coordinates = location.coordinate
print("📸 [PhotoMetadata] ✅ Location found: \(coordinates!.latitude), \(coordinates!.longitude)")
} else {
coordinates = nil
print("📸 [PhotoMetadata] ⚠️ No location in PHAsset, trying ImageIO fallback...")
// Try ImageIO extraction as fallback
if let imageData = await loadImageData(from: asset) {
print("📸 [PhotoMetadata] Loaded image data: \(imageData.count) bytes")
let fallbackMetadata = extractMetadata(from: imageData)
if fallbackMetadata.hasValidLocation || fallbackMetadata.hasValidDate {
print("📸 [PhotoMetadata] ✅ ImageIO fallback found data - date: \(fallbackMetadata.captureDate?.description ?? "nil"), coords: \(fallbackMetadata.coordinates?.latitude ?? 0), \(fallbackMetadata.coordinates?.longitude ?? 0)")
return fallbackMetadata
} else {
print("📸 [PhotoMetadata] ⚠️ ImageIO fallback found no metadata either")
}
}
}
return PhotoMetadata(
let result = PhotoMetadata(
captureDate: asset.creationDate,
coordinates: coordinates
)
print("📸 [PhotoMetadata] Final result - hasDate: \(result.hasValidDate), hasLocation: \(result.hasValidLocation)")
print("📸 [PhotoMetadata] ════════════════════════════════════════")
return result
}
/// Extract metadata from multiple PHAssets
@@ -72,14 +96,58 @@ actor PhotoMetadataExtractor {
/// Extract metadata from raw image data using ImageIO
/// Useful when PHAsset is not available
func extractMetadata(from imageData: Data) -> PhotoMetadata {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
print("📸 [ImageIO] Extracting from image data (\(imageData.count) bytes)")
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else {
print("📸 [ImageIO] ❌ Failed to create CGImageSource")
return .empty
}
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
print("📸 [ImageIO] ❌ Failed to copy properties from source")
return .empty
}
// Debug: Print all available property dictionaries
print("📸 [ImageIO] Available property keys: \(properties.keys.map { $0 as String })")
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] {
print("📸 [ImageIO] EXIF keys: \(exif.keys.map { $0 as String })")
if let dateOriginal = exif[kCGImagePropertyExifDateTimeOriginal] {
print("📸 [ImageIO] EXIF DateTimeOriginal: \(dateOriginal)")
}
if let dateDigitized = exif[kCGImagePropertyExifDateTimeDigitized] {
print("📸 [ImageIO] EXIF DateTimeDigitized: \(dateDigitized)")
}
} else {
print("📸 [ImageIO] ⚠️ No EXIF dictionary found")
}
if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any] {
print("📸 [ImageIO] GPS keys: \(gps.keys.map { $0 as String })")
print("📸 [ImageIO] GPS Latitude: \(gps[kCGImagePropertyGPSLatitude] ?? "nil")")
print("📸 [ImageIO] GPS LatitudeRef: \(gps[kCGImagePropertyGPSLatitudeRef] ?? "nil")")
print("📸 [ImageIO] GPS Longitude: \(gps[kCGImagePropertyGPSLongitude] ?? "nil")")
print("📸 [ImageIO] GPS LongitudeRef: \(gps[kCGImagePropertyGPSLongitudeRef] ?? "nil")")
} else {
print("📸 [ImageIO] ⚠️ No GPS dictionary found")
}
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
print("📸 [ImageIO] TIFF keys: \(tiff.keys.map { $0 as String })")
if let dateTime = tiff[kCGImagePropertyTIFFDateTime] {
print("📸 [ImageIO] TIFF DateTime: \(dateTime)")
}
} else {
print("📸 [ImageIO] ⚠️ No TIFF dictionary found")
}
let captureDate = extractDate(from: properties)
let coordinates = extractCoordinates(from: properties)
print("📸 [ImageIO] Extracted date: \(captureDate?.description ?? "nil")")
print("📸 [ImageIO] Extracted coordinates: \(coordinates?.latitude ?? 0), \(coordinates?.longitude ?? 0)")
return PhotoMetadata(
captureDate: captureDate,
coordinates: coordinates