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>
This commit is contained in:
208
SportsTime/Core/Services/RateLimiter.swift
Normal file
208
SportsTime/Core/Services/RateLimiter.swift
Normal file
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// RateLimiter.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Rate limiting for API providers to respect their rate limits.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
/// Per-provider rate limiting to avoid hitting API limits
|
||||
actor RateLimiter {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct ProviderConfig {
|
||||
let name: String
|
||||
let minInterval: TimeInterval // Minimum time between requests
|
||||
let burstLimit: Int // Max requests in burst window
|
||||
let burstWindow: TimeInterval // Window for burst counting
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var lastRequestTimes: [String: Date] = [:]
|
||||
private var requestCounts: [String: [Date]] = [:]
|
||||
private var configs: [String: ProviderConfig] = [:]
|
||||
|
||||
// MARK: - Default Configurations
|
||||
|
||||
/// Default provider rate limit configurations
|
||||
private static let defaultConfigs: [ProviderConfig] = [
|
||||
ProviderConfig(name: "mlb_stats", minInterval: 0.1, burstLimit: 30, burstWindow: 60), // 10 req/sec
|
||||
ProviderConfig(name: "nhl_stats", minInterval: 0.2, burstLimit: 20, burstWindow: 60), // 5 req/sec
|
||||
ProviderConfig(name: "nba_stats", minInterval: 0.5, burstLimit: 10, burstWindow: 60), // 2 req/sec
|
||||
ProviderConfig(name: "espn", minInterval: 1.0, burstLimit: 30, burstWindow: 60), // 1 req/sec
|
||||
ProviderConfig(name: "sports_reference", minInterval: 3.0, burstLimit: 10, burstWindow: 60) // 1 req/3 sec
|
||||
]
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = RateLimiter()
|
||||
|
||||
private init() {
|
||||
// Load default configs
|
||||
for config in Self.defaultConfigs {
|
||||
configs[config.name] = config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Configure rate limiting for a provider
|
||||
func configureProvider(_ config: ProviderConfig) {
|
||||
configs[config.name] = config
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting
|
||||
|
||||
/// Wait if needed to respect rate limits for a provider
|
||||
/// Returns immediately if rate limit allows, otherwise sleeps until allowed
|
||||
func waitIfNeeded(for provider: String) async {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
await enforceMinInterval(for: provider, interval: config.minInterval)
|
||||
await enforceBurstLimit(for: provider, limit: config.burstLimit, window: config.burstWindow)
|
||||
|
||||
recordRequest(for: provider)
|
||||
}
|
||||
|
||||
/// Check if a request can be made without waiting
|
||||
func canMakeRequest(for provider: String) -> Bool {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
// Check min interval
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < config.minInterval {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check burst limit
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-config.burstWindow)
|
||||
|
||||
if let requests = requestCounts[provider] {
|
||||
let recentRequests = requests.filter { $0 > windowStart }
|
||||
if recentRequests.count >= config.burstLimit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Get estimated wait time until next request is allowed
|
||||
func estimatedWaitTime(for provider: String) -> TimeInterval {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
var maxWait: TimeInterval = 0
|
||||
|
||||
// Check min interval wait
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < config.minInterval {
|
||||
maxWait = max(maxWait, config.minInterval - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Check burst limit wait
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-config.burstWindow)
|
||||
|
||||
if let requests = requestCounts[provider] {
|
||||
let recentRequests = requests.filter { $0 > windowStart }.sorted()
|
||||
if recentRequests.count >= config.burstLimit {
|
||||
// Need to wait until oldest request falls out of window
|
||||
if let oldestInWindow = recentRequests.first {
|
||||
let waitUntil = oldestInWindow.addingTimeInterval(config.burstWindow)
|
||||
let wait = waitUntil.timeIntervalSince(now)
|
||||
maxWait = max(maxWait, wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxWait
|
||||
}
|
||||
|
||||
/// Reset rate limit tracking for a provider
|
||||
func reset(for provider: String) {
|
||||
lastRequestTimes.removeValue(forKey: provider)
|
||||
requestCounts.removeValue(forKey: provider)
|
||||
}
|
||||
|
||||
/// Reset all rate limit tracking
|
||||
func resetAll() {
|
||||
lastRequestTimes.removeAll()
|
||||
requestCounts.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func enforceMinInterval(for provider: String, interval: TimeInterval) async {
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < interval {
|
||||
let waitTime = interval - elapsed
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enforceBurstLimit(for provider: String, limit: Int, window: TimeInterval) async {
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-window)
|
||||
|
||||
// Clean up old requests
|
||||
if var requests = requestCounts[provider] {
|
||||
requests = requests.filter { $0 > windowStart }
|
||||
requestCounts[provider] = requests
|
||||
|
||||
// Check if at limit
|
||||
if requests.count >= limit {
|
||||
// Wait until oldest request falls out of window
|
||||
if let oldestInWindow = requests.sorted().first {
|
||||
let waitUntil = oldestInWindow.addingTimeInterval(window)
|
||||
let waitTime = waitUntil.timeIntervalSince(now)
|
||||
if waitTime > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordRequest(for provider: String) {
|
||||
let now = Date()
|
||||
lastRequestTimes[provider] = now
|
||||
|
||||
if requestCounts[provider] == nil {
|
||||
requestCounts[provider] = []
|
||||
}
|
||||
requestCounts[provider]?.append(now)
|
||||
|
||||
// Clean up old requests periodically
|
||||
if let requests = requestCounts[provider], requests.count > 1000 {
|
||||
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||
requestCounts[provider] = requests.filter { $0 > oneHourAgo }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user