Files
Sportstime/SportsTime/Core/Services/GameMatcher.swift
Trey t 92d808caf5 Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:20:03 -06:00

325 lines
9.6 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: UUID
let game: Game
let stadium: Stadium
let homeTeam: Team
let awayTeam: Team
let confidence: PhotoMatchConfidence
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
}
var matchupDescription: String {
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
}
var fullMatchupDescription: String {
"\(awayTeam.fullName) at \(homeTeam.fullName)"
}
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 {
// Get stadium matches regardless of game matching
var stadiumMatches: [StadiumMatch] = []
if let coordinates = metadata.coordinates {
stadiumMatches = proximityMatcher.findNearbyStadiums(
coordinates: coordinates,
sport: sport
)
}
let matchResult = await matchGame(metadata: metadata, sport: sport)
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
)
}
}