This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
450 lines
15 KiB
Swift
450 lines
15 KiB
Swift
//
|
|
// GameMatcher.swift
|
|
// SportsTime
|
|
//
|
|
// Deterministic game matching from photo metadata.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
// MARK: - No Match Reason
|
|
|
|
enum NoMatchReason: Sendable {
|
|
case noStadiumNearby
|
|
case noGamesOnDate
|
|
case metadataMissing(MetadataMissingReason)
|
|
|
|
enum MetadataMissingReason: Sendable {
|
|
case noLocation
|
|
case noDate
|
|
case noBoth
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .noStadiumNearby:
|
|
return "No stadium found nearby"
|
|
case .noGamesOnDate:
|
|
return "No games found on this date"
|
|
case .metadataMissing(let reason):
|
|
switch reason {
|
|
case .noLocation:
|
|
return "Photo has no location data"
|
|
case .noDate:
|
|
return "Photo has no date information"
|
|
case .noBoth:
|
|
return "Photo has no location or date data"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Game Match Result
|
|
|
|
struct GameMatchCandidate: Identifiable, Sendable {
|
|
let id: String
|
|
let game: Game
|
|
let stadium: Stadium
|
|
let homeTeam: Team
|
|
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
|
|
self.stadium = stadium
|
|
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) {
|
|
let matchId = UUID()
|
|
self.id = "scraped_match_\(matchId.uuidString)"
|
|
self.stadium = stadium
|
|
|
|
// Generate synthetic IDs for scraped games
|
|
let syntheticHomeTeamId = "scraped_team_\(UUID().uuidString)"
|
|
let syntheticAwayTeamId = "scraped_team_\(UUID().uuidString)"
|
|
let syntheticGameId = "scraped_game_\(matchId.uuidString)"
|
|
|
|
// 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: syntheticHomeTeamId,
|
|
name: scrapedGame.homeTeam,
|
|
abbreviation: String(scrapedGame.homeTeam.suffix(3)).uppercased(),
|
|
sport: scrapedGame.sport,
|
|
city: "",
|
|
stadiumId: stadium.id
|
|
)
|
|
|
|
self.awayTeam = Team(
|
|
id: syntheticAwayTeamId,
|
|
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: syntheticGameId,
|
|
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 {
|
|
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
|
|
}
|
|
|
|
var fullMatchupDescription: String {
|
|
"\(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
|
|
formatter.timeStyle = .short
|
|
return formatter.string(from: game.dateTime)
|
|
}
|
|
}
|
|
|
|
enum GameMatchResult: Sendable {
|
|
case singleMatch(GameMatchCandidate) // Auto-select
|
|
case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums)
|
|
case noMatches(NoMatchReason) // Manual entry required
|
|
|
|
var hasMatch: Bool {
|
|
switch self {
|
|
case .singleMatch, .multipleMatches:
|
|
return true
|
|
case .noMatches:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Photo Import Result
|
|
|
|
struct PhotoImportCandidate: Identifiable, Sendable {
|
|
let id: UUID
|
|
let metadata: PhotoMetadata
|
|
let matchResult: GameMatchResult
|
|
let stadiumMatches: [StadiumMatch]
|
|
|
|
init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) {
|
|
self.id = UUID()
|
|
self.metadata = metadata
|
|
self.matchResult = matchResult
|
|
self.stadiumMatches = stadiumMatches
|
|
}
|
|
|
|
/// Best stadium match if available
|
|
var bestStadiumMatch: StadiumMatch? {
|
|
stadiumMatches.first
|
|
}
|
|
|
|
/// Whether this can be auto-processed without user input
|
|
var canAutoProcess: Bool {
|
|
if case .singleMatch(let candidate) = matchResult {
|
|
return candidate.confidence.combined == .autoSelect
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Game Matcher
|
|
|
|
@MainActor
|
|
final class GameMatcher {
|
|
static let shared = GameMatcher()
|
|
|
|
private let dataProvider = AppDataProvider.shared
|
|
private let proximityMatcher = StadiumProximityMatcher.shared
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Primary Matching
|
|
|
|
/// Match photo metadata to a game
|
|
/// Uses deterministic rules - never guesses
|
|
func matchGame(
|
|
metadata: PhotoMetadata,
|
|
sport: Sport? = nil
|
|
) async -> GameMatchResult {
|
|
// 1. Check for required metadata
|
|
guard metadata.hasValidLocation else {
|
|
let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth
|
|
return .noMatches(.metadataMissing(reason))
|
|
}
|
|
|
|
guard metadata.hasValidDate, let photoDate = metadata.captureDate else {
|
|
return .noMatches(.metadataMissing(.noDate))
|
|
}
|
|
|
|
guard let coordinates = metadata.coordinates else {
|
|
return .noMatches(.metadataMissing(.noLocation))
|
|
}
|
|
|
|
// 2. Find nearby stadiums
|
|
let stadiumMatches = proximityMatcher.findNearbyStadiums(
|
|
coordinates: coordinates,
|
|
sport: sport
|
|
)
|
|
|
|
guard !stadiumMatches.isEmpty else {
|
|
return .noMatches(.noStadiumNearby)
|
|
}
|
|
|
|
// 3. Find games at those stadiums on/around that date
|
|
var candidates: [GameMatchCandidate] = []
|
|
|
|
for stadiumMatch in stadiumMatches {
|
|
let games = await findGames(
|
|
at: stadiumMatch.stadium,
|
|
around: photoDate,
|
|
sport: sport
|
|
)
|
|
|
|
for game in games {
|
|
// Look up teams
|
|
guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }),
|
|
let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else {
|
|
continue
|
|
}
|
|
|
|
// Calculate confidence
|
|
let confidence = proximityMatcher.calculateMatchConfidence(
|
|
stadiumMatch: stadiumMatch,
|
|
photoDate: photoDate,
|
|
gameDate: game.dateTime
|
|
)
|
|
|
|
// Only include if temporal confidence is acceptable
|
|
if confidence.temporal != .outOfRange {
|
|
candidates.append(GameMatchCandidate(
|
|
game: game,
|
|
stadium: stadiumMatch.stadium,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
confidence: confidence
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Return based on matches found
|
|
if candidates.isEmpty {
|
|
return .noMatches(.noGamesOnDate)
|
|
} else if candidates.count == 1 {
|
|
return .singleMatch(candidates[0])
|
|
} else {
|
|
// Sort by confidence (best first)
|
|
let sorted = candidates.sorted { c1, c2 in
|
|
c1.confidence.combined > c2.confidence.combined
|
|
}
|
|
return .multipleMatches(sorted)
|
|
}
|
|
}
|
|
|
|
// MARK: - Full Import Processing
|
|
|
|
/// Process a photo for import, returning full match context
|
|
func processPhotoForImport(
|
|
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 {
|
|
stadiumMatches = proximityMatcher.findNearbyStadiums(
|
|
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")
|
|
}
|
|
|
|
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,
|
|
matchResult: matchResult,
|
|
stadiumMatches: stadiumMatches
|
|
)
|
|
}
|
|
|
|
/// Process multiple photos for import
|
|
func processPhotosForImport(
|
|
_ metadataList: [PhotoMetadata],
|
|
sport: Sport? = nil
|
|
) async -> [PhotoImportCandidate] {
|
|
var results: [PhotoImportCandidate] = []
|
|
|
|
for metadata in metadataList {
|
|
let candidate = await processPhotoForImport(metadata: metadata, sport: sport)
|
|
results.append(candidate)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
/// Find games at a stadium around a given date (±1 day for timezone/tailgating)
|
|
private func findGames(
|
|
at stadium: Stadium,
|
|
around date: Date,
|
|
sport: Sport?
|
|
) async -> [Game] {
|
|
let calendar = Calendar.current
|
|
|
|
// Search window: ±1 day
|
|
guard let startDate = calendar.date(byAdding: .day, value: -1, to: date),
|
|
let endDate = calendar.date(byAdding: .day, value: 2, to: date) else {
|
|
return []
|
|
}
|
|
|
|
// Determine which sports to query
|
|
let sports: Set<Sport> = sport != nil ? [sport!] : Set(Sport.allCases)
|
|
|
|
do {
|
|
let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
|
|
|
// Filter by stadium
|
|
let games = allGames.filter { $0.stadiumId == stadium.id }
|
|
|
|
return games
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Batch Processing Helpers
|
|
|
|
extension GameMatcher {
|
|
/// Separate photos into categories for UI
|
|
struct CategorizedImports: Sendable {
|
|
let autoProcessable: [PhotoImportCandidate]
|
|
let needsConfirmation: [PhotoImportCandidate]
|
|
let needsManualEntry: [PhotoImportCandidate]
|
|
}
|
|
|
|
nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports {
|
|
var auto: [PhotoImportCandidate] = []
|
|
var confirm: [PhotoImportCandidate] = []
|
|
var manual: [PhotoImportCandidate] = []
|
|
|
|
for candidate in candidates {
|
|
switch candidate.matchResult {
|
|
case .singleMatch(let match):
|
|
if match.confidence.combined == .autoSelect {
|
|
auto.append(candidate)
|
|
} else {
|
|
confirm.append(candidate)
|
|
}
|
|
case .multipleMatches:
|
|
confirm.append(candidate)
|
|
case .noMatches:
|
|
manual.append(candidate)
|
|
}
|
|
}
|
|
|
|
return CategorizedImports(
|
|
autoProcessable: auto,
|
|
needsConfirmation: confirm,
|
|
needsManualEntry: manual
|
|
)
|
|
}
|
|
}
|