Files
Sportstime/SportsTime/Core/Services/GameMatcher.swift
Trey t 1703ca5b0f refactor: change domain model IDs from UUID to String canonical IDs
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>
2026-01-12 09:24:33 -06:00

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
)
}
}