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>
325 lines
9.6 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|