Files
Sportstime/SportsTime/Core/Services/StadiumProximityMatcher.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

349 lines
11 KiB
Swift

//
// StadiumProximityMatcher.swift
// SportsTime
//
// Service for matching GPS coordinates to nearby stadiums.
//
import Foundation
import CoreLocation
// MARK: - Match Confidence
enum MatchConfidence: Sendable {
case high // < 500m from stadium center
case medium // 500m - 2km
case low // 2km - 5km
case none // > 5km or no coordinates
nonisolated var description: String {
switch self {
case .high: return "High (within 500m)"
case .medium: return "Medium (500m - 2km)"
case .low: return "Low (2km - 5km)"
case .none: return "No match"
}
}
/// Should auto-select this match without user confirmation?
nonisolated var shouldAutoSelect: Bool {
switch self {
case .high: return true
default: return false
}
}
}
// Explicit nonisolated Equatable and Comparable conformance
extension MatchConfidence: Equatable {
nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
switch (lhs, rhs) {
case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none):
return true
default:
return false
}
}
}
extension MatchConfidence: Comparable {
nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
let order: [MatchConfidence] = [.none, .low, .medium, .high]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Stadium Match
struct StadiumMatch: Identifiable, Sendable {
let id: UUID
let stadium: Stadium
let distance: CLLocationDistance
let confidence: MatchConfidence
init(stadium: Stadium, distance: CLLocationDistance) {
self.id = stadium.id
self.stadium = stadium
self.distance = distance
self.confidence = Self.calculateConfidence(for: distance)
}
var formattedDistance: String {
if distance < 1000 {
return String(format: "%.0fm away", distance)
} else {
return String(format: "%.1f km away", distance / 1000)
}
}
private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence {
switch distance {
case 0..<500:
return .high
case 500..<2000:
return .medium
case 2000..<5000:
return .low
default:
return .none
}
}
}
// MARK: - Temporal Confidence
enum TemporalConfidence: Sendable {
case exactDay // Same local date as game
case adjacentDay // ±1 day (tailgating, next morning)
case outOfRange // >1 day difference
nonisolated var description: String {
switch self {
case .exactDay: return "Same day"
case .adjacentDay: return "Adjacent day (±1)"
case .outOfRange: return "Out of range"
}
}
}
extension TemporalConfidence: Equatable {
nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
switch (lhs, rhs) {
case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange):
return true
default:
return false
}
}
}
extension TemporalConfidence: Comparable {
nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Combined Confidence
enum CombinedConfidence: Sendable {
case autoSelect // High spatial + exactDay auto-select
case userConfirm // Medium spatial OR adjacentDay user confirms
case manualOnly // Low spatial OR outOfRange manual entry
nonisolated var description: String {
switch self {
case .autoSelect: return "Auto-select"
case .userConfirm: return "Needs confirmation"
case .manualOnly: return "Manual entry required"
}
}
nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence {
// Low spatial or out of range manual only
switch spatial {
case .low, .none:
return .manualOnly
default:
break
}
switch temporal {
case .outOfRange:
return .manualOnly
default:
break
}
// High spatial + exact day auto-select
switch (spatial, temporal) {
case (.high, .exactDay):
return .autoSelect
default:
break
}
// Everything else needs user confirmation
return .userConfirm
}
}
extension CombinedConfidence: Equatable {
nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
switch (lhs, rhs) {
case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly):
return true
default:
return false
}
}
}
extension CombinedConfidence: Comparable {
nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Photo Match Confidence
struct PhotoMatchConfidence: Sendable {
let spatial: MatchConfidence
let temporal: TemporalConfidence
let combined: CombinedConfidence
nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) {
self.spatial = spatial
self.temporal = temporal
self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal)
}
}
// MARK: - Stadium Proximity Matcher
@MainActor
final class StadiumProximityMatcher {
static let shared = StadiumProximityMatcher()
// Configuration constants
static let highConfidenceRadius: CLLocationDistance = 500 // 500m
static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km
static let searchRadius: CLLocationDistance = 5000 // 5km default
static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating
private let dataProvider = AppDataProvider.shared
private init() {}
// MARK: - Stadium Matching
/// Find stadiums within radius of coordinates
func findNearbyStadiums(
coordinates: CLLocationCoordinate2D,
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius,
sport: Sport? = nil
) -> [StadiumMatch] {
let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
var stadiums = dataProvider.stadiums
// Filter by sport if specified
if let sport = sport {
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
stadiums = stadiums.filter { stadiumIds.contains($0.id) }
}
// Calculate distances and filter by radius
var matches: [StadiumMatch] = []
for stadium in stadiums {
let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
let distance = photoLocation.distance(from: stadiumLocation)
if distance <= radius {
matches.append(StadiumMatch(stadium: stadium, distance: distance))
}
}
// Sort by distance (closest first)
return matches.sorted { $0.distance < $1.distance }
}
/// Find best matching stadium (single result)
func findBestMatch(
coordinates: CLLocationCoordinate2D,
sport: Sport? = nil
) -> StadiumMatch? {
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
return matches.first
}
/// Check if coordinates are near any stadium
func isNearStadium(
coordinates: CLLocationCoordinate2D,
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius
) -> Bool {
let matches = findNearbyStadiums(coordinates: coordinates, radius: radius)
return !matches.isEmpty
}
// MARK: - Temporal Matching
/// Calculate temporal confidence between photo date and game date
nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence {
let calendar = Calendar.current
// Normalize to day boundaries
let photoDay = calendar.startOfDay(for: photoDate)
let gameDay = calendar.startOfDay(for: gameDate)
let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max)
switch daysDifference {
case 0:
return .exactDay
case 1:
return .adjacentDay
default:
return .outOfRange
}
}
/// Calculate combined confidence for a photo-stadium-game match
nonisolated func calculateMatchConfidence(
stadiumMatch: StadiumMatch,
photoDate: Date?,
gameDate: Date?
) -> PhotoMatchConfidence {
let spatial = stadiumMatch.confidence
let temporal: TemporalConfidence
if let photoDate = photoDate, let gameDate = gameDate {
temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate)
} else {
// Missing date information
temporal = .outOfRange
}
return PhotoMatchConfidence(spatial: spatial, temporal: temporal)
}
}
// MARK: - Batch Processing
extension StadiumProximityMatcher {
/// Find matches for multiple photos
func findMatchesForPhotos(
_ metadata: [PhotoMetadata],
sport: Sport? = nil
) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] {
var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = []
for photo in metadata {
if let coordinates = photo.coordinates {
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
results.append((metadata: photo, matches: matches))
} else {
// No coordinates - empty matches
results.append((metadata: photo, matches: []))
}
}
return results
}
}