Add StadiumAlias CloudKit sync and offline-first data architecture

- Add CKStadiumAlias model for CloudKit record mapping
- Add fetchStadiumAliases/fetchStadiumAliasChanges to CloudKitService
- Add syncStadiumAliases to CanonicalSyncService for delta sync
- Add subscribeToStadiumAliasUpdates for push notifications
- Update cloudkit_import.py with --stadium-aliases-only option

Data Architecture Updates:
- Remove obsolete provider files (CanonicalDataProvider, CloudKitDataProvider, StubDataProvider)
- AppDataProvider now reads exclusively from SwiftData
- Add background CloudKit sync on app startup (non-blocking)
- Document data architecture in CLAUDE.md

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-08 22:20:07 -06:00
parent 588938d2a1
commit 1ee47df53e
12 changed files with 482 additions and 780 deletions

View File

@@ -50,11 +50,75 @@ This is an iOS app for planning multi-stop sports road trips. It uses **Clean MV
- `Services/POISearchService` - Finds nearby restaurants, attractions via MKLocalSearch
- `Services/PDFAssetPrefetcher` - Parallel prefetching of all PDF assets
### Data Storage Strategy
### Data Architecture (Offline-First)
- **CloudKit Public DB**: Read-only schedules, stadiums, teams (shared across all users)
- **SwiftData Local**: User's saved trips, preferences, cached schedules
- **No network dependency** for trip planning once schedules are synced
**CRITICAL: `AppDataProvider.shared` is the ONLY source of truth for canonical data.**
All code that reads stadiums, teams, games, or league structure MUST use `AppDataProvider.shared`. Never access CloudKit or SwiftData directly for this data.
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Bundled JSON │ │ SwiftData │ │ CloudKit │
│ (App Bundle) │ │ (Local Store) │ │ (Remote Sync) │
└────────┬─────────┘ └────────▲─────────┘ └────────┬─────────┘
│ │ │
│ Bootstrap │ Read │ Background
│ (first launch) │ │ Sync
▼ │ ▼
┌─────────────────────────────────┴────────────────────────────────────┐
│ AppDataProvider.shared │
│ (Single Source of Truth) │
└─────────────────────────────────┬────────────────────────────────────┘
All Features, ViewModels, Services
```
**App Startup Flow:**
1. **Bootstrap** (first launch only): `BootstrapService` loads bundled JSON → SwiftData
2. **Configure**: `AppDataProvider.shared.configure(with: context)`
3. **Load**: `AppDataProvider.shared.loadInitialData()` reads SwiftData into memory
4. **App usable immediately** with local data
5. **Background sync**: `CanonicalSyncService.syncAll()` fetches CloudKit → updates SwiftData (non-blocking)
6. **Reload**: After sync completes, `loadInitialData()` refreshes in-memory cache
**Offline Handling:**
- SwiftData always has data (from bootstrap or last successful CloudKit sync)
- If CloudKit unavailable, app continues with existing local data
- First launch + offline = bootstrap data used
**Canonical Data Models** (in SwiftData, synced from CloudKit):
- `CanonicalStadium``Stadium` (domain)
- `CanonicalTeam``Team` (domain)
- `CanonicalGame``Game` (domain)
- `LeagueStructureModel`
- `TeamAlias`, `StadiumAlias`
**User Data Models** (local only, not synced):
- `SavedTrip`, `StadiumVisit`, `UserPreferences`, `Achievement`
**Correct Usage:**
```swift
// CORRECT - Use AppDataProvider
let stadiums = AppDataProvider.shared.stadiums
let teams = AppDataProvider.shared.teams
let games = try await AppDataProvider.shared.fetchGames(sports: sports, startDate: start, endDate: end)
let richGames = try await AppDataProvider.shared.fetchRichGames(...)
// WRONG - Never access CloudKit directly for reads
let stadiums = try await CloudKitService.shared.fetchStadiums() // NO!
// WRONG - Never fetch canonical data from SwiftData directly
let descriptor = FetchDescriptor<CanonicalStadium>()
let stadiums = try context.fetch(descriptor) // NO! (except in DataProvider/Sync/Bootstrap)
```
**Allowed Direct SwiftData Access:**
- `DataProvider.swift` - It IS the data provider
- `CanonicalSyncService.swift` - CloudKit → SwiftData sync
- `BootstrapService.swift` - Initial data population
- `StadiumIdentityService.swift` - Specialized identity resolution for stadium renames
- User data (e.g., `StadiumVisit`) - Not canonical data
### Key Data Flow

View File

@@ -14,6 +14,7 @@ Usage:
python cloudkit_import.py --key-id XX --key-file key.p8 # Import all
python cloudkit_import.py --stadiums-only ... # Stadiums first
python cloudkit_import.py --games-only ... # Games after
python cloudkit_import.py --stadium-aliases-only ... # Stadium aliases only
python cloudkit_import.py --delete-all ... # Delete then import
python cloudkit_import.py --delete-only ... # Delete only (no import)
"""
@@ -44,26 +45,27 @@ def show_menu():
print("\n" + "="*50)
print("CloudKit Import - Select Action")
print("="*50)
print("\n 1. Import all (stadiums, teams, games, league structure, team aliases)")
print("\n 1. Import all (stadiums, teams, games, league structure, team aliases, stadium aliases)")
print(" 2. Stadiums only")
print(" 3. Games only")
print(" 4. League structure only")
print(" 5. Team aliases only")
print(" 6. Canonical only (league structure + team aliases)")
print(" 7. Delete all then import")
print(" 8. Delete only (no import)")
print(" 9. Dry run (preview only)")
print(" 6. Stadium aliases only")
print(" 7. Canonical only (league structure + team aliases + stadium aliases)")
print(" 8. Delete all then import")
print(" 9. Delete only (no import)")
print(" 10. Dry run (preview only)")
print(" 0. Exit")
print()
while True:
try:
choice = input("Enter choice [1-9, 0 to exit]: ").strip()
choice = input("Enter choice [1-10, 0 to exit]: ").strip()
if choice == '0':
return None
if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9']:
if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']:
return int(choice)
print("Invalid choice. Please enter 1-9 or 0.")
print("Invalid choice. Please enter 1-10 or 0.")
except (EOFError, KeyboardInterrupt):
print("\nExiting.")
return None
@@ -257,7 +259,8 @@ def main():
p.add_argument('--games-only', action='store_true')
p.add_argument('--league-structure-only', action='store_true', help='Import only league structure')
p.add_argument('--team-aliases-only', action='store_true', help='Import only team aliases')
p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases)')
p.add_argument('--stadium-aliases-only', action='store_true', help='Import only stadium aliases')
p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases + stadium aliases)')
p.add_argument('--delete-all', action='store_true', help='Delete all records before importing')
p.add_argument('--delete-only', action='store_true', help='Only delete records, do not import')
p.add_argument('--dry-run', action='store_true')
@@ -268,8 +271,8 @@ def main():
# Show interactive menu if no action flags provided or --interactive
has_action_flag = any([
args.stadiums_only, args.games_only, args.league_structure_only,
args.team_aliases_only, args.canonical_only, args.delete_all,
args.delete_only, args.dry_run
args.team_aliases_only, args.stadium_aliases_only, args.canonical_only,
args.delete_all, args.delete_only, args.dry_run
])
if args.interactive or not has_action_flag:
@@ -288,13 +291,15 @@ def main():
args.league_structure_only = True
elif choice == 5: # Team aliases only
args.team_aliases_only = True
elif choice == 6: # Canonical only
elif choice == 6: # Stadium aliases only
args.stadium_aliases_only = True
elif choice == 7: # Canonical only
args.canonical_only = True
elif choice == 7: # Delete all then import
elif choice == 8: # Delete all then import
args.delete_all = True
elif choice == 8: # Delete only
elif choice == 9: # Delete only
args.delete_only = True
elif choice == 9: # Dry run
elif choice == 10: # Dry run
args.dry_run = True
print(f"\n{'='*50}")
@@ -308,7 +313,8 @@ def main():
games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else []
league_structure = json.load(open(data_dir / 'league_structure.json')) if (data_dir / 'league_structure.json').exists() else []
team_aliases = json.load(open(data_dir / 'team_aliases.json')) if (data_dir / 'team_aliases.json').exists() else []
print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases\n")
stadium_aliases = json.load(open(data_dir / 'stadium_aliases.json')) if (data_dir / 'stadium_aliases.json').exists() else []
print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases, {len(stadium_aliases)} stadium aliases\n")
ck = None
if not args.dry_run:
@@ -325,7 +331,7 @@ def main():
print("--- Deleting Existing Records ---")
# Delete in order: dependent records first, then base records
for record_type in ['Game', 'TeamAlias', 'Team', 'LeagueStructure', 'Stadium']:
for record_type in ['Game', 'TeamAlias', 'StadiumAlias', 'Team', 'LeagueStructure', 'Stadium']:
print(f" Deleting {record_type} records...")
deleted = ck.delete_all(record_type, verbose=args.verbose)
print(f" Deleted {deleted} {record_type} records")
@@ -336,15 +342,16 @@ def main():
print()
return
stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0}
stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0, 'stadium_aliases': 0}
team_map = {}
# Determine what to import based on flags
import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only)
import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only)
import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only
import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only
import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only
import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only and not args.stadium_aliases_only)
import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only and not args.stadium_aliases_only)
import_stadium_aliases = args.stadium_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only and not args.team_aliases_only)
# Build stadium UUID lookup (stadium string ID -> UUID)
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
@@ -522,8 +529,40 @@ def main():
})
stats['team_aliases'] = import_data(ck, recs, 'team aliases', args.dry_run, args.verbose)
# Import stadium aliases
if import_stadium_aliases and stadium_aliases:
print("--- Stadium Aliases ---")
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
recs = []
for sa in stadium_aliases:
fields = {
'aliasName': {'value': sa['alias_name'].lower()}, # Normalize to lowercase
'stadiumCanonicalId': {'value': sa['stadium_canonical_id']},
'schemaVersion': {'value': 1},
'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'},
}
# Add optional date fields
if sa.get('valid_from'):
try:
dt = datetime.strptime(sa['valid_from'], '%Y-%m-%d')
fields['validFrom'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'}
except:
pass
if sa.get('valid_until'):
try:
dt = datetime.strptime(sa['valid_until'], '%Y-%m-%d')
fields['validUntil'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'}
except:
pass
recs.append({
'recordType': 'StadiumAlias',
'recordName': sa['alias_name'].lower(), # Use alias_name as recordName (unique key)
'fields': fields
})
stats['stadium_aliases'] = import_data(ck, recs, 'stadium aliases', args.dry_run, args.verbose)
print(f"\n{'='*50}")
print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases")
print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases, {stats['stadium_aliases']} stadium aliases")
if args.dry_run:
print("[DRY RUN - nothing imported]")
print()

View File

@@ -294,6 +294,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SportsTime/Info.plist;
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "SportsTime uses your photos to import stadium visits by reading GPS location and date from photo metadata.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -328,6 +329,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SportsTime/Info.plist;
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "SportsTime uses your photos to import stadium visits by reading GPS location and date from photo metadata.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;

View File

@@ -17,6 +17,7 @@ enum CKRecordType {
static let sport = "Sport"
static let leagueStructure = "LeagueStructure"
static let teamAlias = "TeamAlias"
static let stadiumAlias = "StadiumAlias"
}
// MARK: - CKTeam
@@ -273,6 +274,55 @@ struct CKLeagueStructure {
}
}
// MARK: - CKStadiumAlias
struct CKStadiumAlias {
static let aliasNameKey = "aliasName"
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let validFromKey = "validFrom"
static let validUntilKey = "validUntil"
static let schemaVersionKey = "schemaVersion"
static let lastModifiedKey = "lastModified"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(model: StadiumAlias) {
let record = CKRecord(recordType: CKRecordType.stadiumAlias, recordID: CKRecord.ID(recordName: model.aliasName))
record[CKStadiumAlias.aliasNameKey] = model.aliasName
record[CKStadiumAlias.stadiumCanonicalIdKey] = model.stadiumCanonicalId
record[CKStadiumAlias.validFromKey] = model.validFrom
record[CKStadiumAlias.validUntilKey] = model.validUntil
record[CKStadiumAlias.schemaVersionKey] = model.schemaVersion
record[CKStadiumAlias.lastModifiedKey] = model.lastModified
self.record = record
}
/// Convert to SwiftData model for local storage
func toModel() -> StadiumAlias? {
guard let aliasName = record[CKStadiumAlias.aliasNameKey] as? String,
let stadiumCanonicalId = record[CKStadiumAlias.stadiumCanonicalIdKey] as? String
else { return nil }
let validFrom = record[CKStadiumAlias.validFromKey] as? Date
let validUntil = record[CKStadiumAlias.validUntilKey] as? Date
let schemaVersion = record[CKStadiumAlias.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKStadiumAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
return StadiumAlias(
aliasName: aliasName,
stadiumCanonicalId: stadiumCanonicalId,
validFrom: validFrom,
validUntil: validUntil,
schemaVersion: schemaVersion,
lastModified: lastModified
)
}
}
// MARK: - CKTeamAlias
struct CKTeamAlias {

View File

@@ -1,234 +0,0 @@
//
// CanonicalDataProvider.swift
// SportsTime
//
// DataProvider implementation that reads from SwiftData canonical models.
// This is the primary data source after bootstrap completes.
//
import Foundation
import SwiftData
actor CanonicalDataProvider: DataProvider {
// MARK: - Properties
private let modelContainer: ModelContainer
// Caches for converted domain objects (rebuilt on first access)
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByCanonicalId: [String: Team] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamUUIDByCanonicalId: [String: UUID] = [:]
private var stadiumUUIDByCanonicalId: [String: UUID] = [:]
// MARK: - Initialization
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadCachesIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Fetch canonical games within date range
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
sportStrings.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate &&
game.deprecatedAt == nil
},
sortBy: [SortDescriptor(\.dateTime)]
)
let canonicalGames = try context.fetch(descriptor)
// Convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Search by UUID
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.uuid == id && game.deprecatedAt == nil
}
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadCachesIfNeeded()
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Additional Queries
/// Fetch stadium by canonical ID (useful for visit tracking)
func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? {
try await loadCachesIfNeeded()
return stadiumsByCanonicalId[canonicalId]
}
/// Fetch team by canonical ID
func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? {
try await loadCachesIfNeeded()
return teamsByCanonicalId[canonicalId]
}
/// Find stadium by name (matches aliases)
func findStadium(byName name: String) async throws -> Stadium? {
let context = ModelContext(modelContainer)
// Precompute lowercased name outside the predicate
let lowercasedName = name.lowercased()
// First try exact alias match
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first,
let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) {
return try await fetchStadium(byCanonicalId: stadiumCanonicalId)
}
return nil
}
/// Invalidate caches (call after sync completes)
func invalidateCaches() {
cachedTeams = nil
cachedStadiums = nil
teamsByCanonicalId.removeAll()
stadiumsByCanonicalId.removeAll()
teamUUIDByCanonicalId.removeAll()
stadiumUUIDByCanonicalId.removeAll()
}
// MARK: - Private Helpers
private func loadCachesIfNeeded() async throws {
guard cachedTeams == nil else { return }
let context = ModelContext(modelContainer)
// Load stadiums
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
cachedStadiums = canonicalStadiums.map { canonical in
let stadium = canonical.toDomain()
stadiumsByCanonicalId[canonical.canonicalId] = stadium
stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id
return stadium
}
// Load teams
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.deprecatedAt == nil
}
)
let canonicalTeams = try context.fetch(teamDescriptor)
cachedTeams = canonicalTeams.compactMap { canonical -> Team? in
guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
// Generate a placeholder UUID for teams without known stadiums
let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId)
let team = canonical.toDomain(stadiumUUID: placeholderUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
}
}

View File

@@ -42,12 +42,13 @@ actor CanonicalSyncService {
let gamesUpdated: Int
let leagueStructuresUpdated: Int
let teamAliasesUpdated: Int
let stadiumAliasesUpdated: Int
let skippedIncompatible: Int
let skippedOlder: Int
let duration: TimeInterval
var totalUpdated: Int {
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
}
var isEmpty: Bool { totalUpdated == 0 }
@@ -81,7 +82,7 @@ actor CanonicalSyncService {
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
skippedIncompatible: 0, skippedOlder: 0,
duration: 0
)
@@ -101,6 +102,7 @@ actor CanonicalSyncService {
var totalGames = 0
var totalLeagueStructures = 0
var totalTeamAliases = 0
var totalStadiumAliases = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
@@ -138,13 +140,21 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
let (games, skipIncompat5, skipOlder5) = try await syncGames(
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
context: context,
since: syncState.lastSuccessfulSync
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
let (games, skipIncompat6, skipOlder6) = try await syncGames(
context: context,
since: syncState.lastSuccessfulSync
)
totalGames = games
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
// Mark sync successful
syncState.syncInProgress = false
@@ -176,6 +186,7 @@ actor CanonicalSyncService {
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime)
@@ -346,6 +357,30 @@ actor CanonicalSyncService {
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncStadiumAliases(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeStadiumAlias(remoteAlias, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
// MARK: - Merge Logic
private enum MergeResult {
@@ -631,4 +666,41 @@ actor CanonicalSyncService {
return .applied
}
}
@MainActor
private func mergeStadiumAlias(
_ remote: StadiumAlias,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteAliasName = remote.aliasName
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate { $0.aliasName == remoteAliasName }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on StadiumAlias)
existing.stadiumCanonicalId = remote.stadiumCanonicalId
existing.validFrom = remote.validFrom
existing.validUntil = remote.validUntil
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
}

View File

@@ -1,108 +0,0 @@
//
// CloudKitDataProvider.swift
// SportsTime
//
// Wraps CloudKitService to conform to DataProvider protocol
//
import Foundation
actor CloudKitDataProvider: DataProvider {
private let cloudKit = CloudKitService.shared
// MARK: - Availability
func checkAvailability() async throws {
try await cloudKit.checkAvailabilityWithError()
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
do {
try await checkAvailability()
return try await cloudKit.fetchTeams(for: sport)
} catch {
throw CloudKitError.from(error)
}
}
func fetchAllTeams() async throws -> [Team] {
do {
try await checkAvailability()
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
} catch {
throw CloudKitError.from(error)
}
}
func fetchStadiums() async throws -> [Stadium] {
do {
try await checkAvailability()
return try await cloudKit.fetchStadiums()
} catch {
throw CloudKitError.from(error)
}
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
do {
try await checkAvailability()
return try await cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
} catch {
throw CloudKitError.from(error)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
do {
try await checkAvailability()
return try await cloudKit.fetchGame(by: id)
} catch {
throw CloudKitError.from(error)
}
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
do {
try await checkAvailability()
// Fetch all required data
async let gamesTask = cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
async let teamsTask = fetchAllTeamsInternal()
async let stadiumsTask = cloudKit.fetchStadiums()
let (games, teams, stadiums) = try await (gamesTask, teamsTask, stadiumsTask)
let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
} catch {
throw CloudKitError.from(error)
}
}
// Internal helper to avoid duplicate availability checks
private func fetchAllTeamsInternal() async throws -> [Team] {
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
}
}

View File

@@ -228,6 +228,24 @@ actor CloudKitService {
}
}
func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let stadiumId = stadiumCanonicalId {
predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Delta Sync (Date-Based for Public Database)
/// Fetch league structure records modified after the given date
@@ -270,6 +288,26 @@ actor CloudKitService {
}
}
/// Fetch stadium alias records modified after the given date
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Sync Status
func checkAccountStatus() async -> CKAccountStatus {
@@ -327,10 +365,26 @@ actor CloudKitService {
try await publicDatabase.save(subscription)
}
func subscribeToStadiumAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadiumAlias,
predicate: NSPredicate(value: true),
subscriptionID: "stadium-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
/// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates()
}
}

View File

@@ -2,61 +2,90 @@
// DataProvider.swift
// SportsTime
//
// Unified data provider that reads from SwiftData for offline support.
// Data is bootstrapped from bundled JSON and can be synced via CloudKit.
//
import Foundation
import SwiftData
import Combine
/// Protocol defining data operations for teams, stadiums, and games
protocol DataProvider: Sendable {
func fetchTeams(for sport: Sport) async throws -> [Team]
func fetchAllTeams() async throws -> [Team]
func fetchStadiums() async throws -> [Stadium]
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
func fetchGame(by id: UUID) async throws -> Game?
// Resolved data (with team/stadium references)
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame]
}
/// Environment-aware data provider that switches between stub and CloudKit
/// Environment-aware data provider that reads from SwiftData (offline-first)
@MainActor
final class AppDataProvider: ObservableObject {
static let shared = AppDataProvider()
private let provider: any DataProvider
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
@Published private(set) var isUsingStubData: Bool
private var teamsById: [UUID: Team] = [:]
private var stadiumsById: [UUID: Stadium] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamsByCanonicalId: [String: Team] = [:]
private init() {
#if targetEnvironment(simulator)
self.provider = StubDataProvider()
self.isUsingStubData = true
#else
self.provider = CloudKitDataProvider()
self.isUsingStubData = false
#endif
// Canonical ID lookups for game conversion
private var canonicalTeamUUIDs: [String: UUID] = [:]
private var canonicalStadiumUUIDs: [String: UUID] = [:]
private var modelContext: ModelContext?
private init() {}
// MARK: - Configuration
/// Set the model context for SwiftData access
func configure(with context: ModelContext) {
self.modelContext = context
}
// MARK: - Data Loading
/// Load all data from SwiftData into memory for fast access
func loadInitialData() async {
guard let context = modelContext else {
errorMessage = "Model context not configured"
return
}
isLoading = true
error = nil
errorMessage = nil
do {
async let teamsTask = provider.fetchAllTeams()
async let stadiumsTask = provider.fetchStadiums()
// Fetch canonical stadiums from SwiftData
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
let (loadedTeams, loadedStadiums) = try await (teamsTask, stadiumsTask)
// Convert to domain models and build lookups
var loadedStadiums: [Stadium] = []
for canonical in canonicalStadiums {
let stadium = canonical.toDomain()
loadedStadiums.append(stadium)
stadiumsByCanonicalId[canonical.canonicalId] = stadium
canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id
}
// Fetch canonical teams from SwiftData
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalTeams = try context.fetch(teamDescriptor)
// Convert to domain models
var loadedTeams: [Team] = []
for canonical in canonicalTeams {
// Get stadium UUID for this team
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
loadedTeams.append(team)
teamsByCanonicalId[canonical.canonicalId] = team
canonicalTeamUUIDs[canonical.canonicalId] = team.id
}
self.teams = loadedTeams
self.stadiums = loadedStadiums
@@ -64,9 +93,7 @@ final class AppDataProvider: ObservableObject {
// Build lookup dictionaries
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) })
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
} catch {
self.error = error
self.errorMessage = error.localizedDescription
@@ -98,12 +125,71 @@ final class AppDataProvider: ObservableObject {
teams.filter { $0.sport == sport }
}
// MARK: - Game Fetching
/// Fetch games from SwiftData within date range
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}
)
let canonicalGames = try context.fetch(descriptor)
// Filter by sport and convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard sportStrings.contains(canonical.sport) else { return nil }
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
}
}
/// Fetch a single game by ID
func fetchGame(by id: UUID) async throws -> Game? {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let idString = id.uuidString
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.canonicalId == idString }
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
}
/// Fetch games with full team and stadium data
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
let games = try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
@@ -124,3 +210,16 @@ final class AppDataProvider: ObservableObject {
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Errors
enum DataProviderError: Error, LocalizedError {
case contextNotConfigured
var errorDescription: String? {
switch self {
case .contextNotConfigured:
return "Data provider not configured with model context"
}
}
}

View File

@@ -1,373 +0,0 @@
//
// StubDataProvider.swift
// SportsTime
//
// Provides real data from bundled JSON files for Simulator testing
//
import Foundation
import CryptoKit
actor StubDataProvider: DataProvider {
// MARK: - JSON Models
private struct JSONGame: Codable {
let id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team: String
let away_team: String
let home_team_abbrev: String
let away_team_abbrev: String
let venue: String
let source: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONStadium: Codable {
let id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let team_abbrevs: [String]
let source: String
let year_opened: Int?
}
// MARK: - Cached Data
private var cachedGames: [Game]?
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByAbbrev: [String: Team] = [:]
private var stadiumsByVenue: [String: Stadium] = [:]
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadAllDataIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadAllDataIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadAllDataIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadAllDataIfNeeded()
return (cachedGames ?? []).filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadAllDataIfNeeded()
return cachedGames?.first { $0.id == id }
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadAllDataIfNeeded()
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Data Loading
private func loadAllDataIfNeeded() async throws {
guard cachedGames == nil else { return }
// Load stadiums first
let jsonStadiums = try loadStadiumsJSON()
cachedStadiums = jsonStadiums.map { convertStadium($0) }
// Build stadium lookup by venue name
for stadium in cachedStadiums ?? [] {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Load games and extract teams
let jsonGames = try loadGamesJSON()
// Build teams from games data
var teamsDict: [String: Team] = [:]
for jsonGame in jsonGames {
let sport = parseSport(jsonGame.sport)
// Home team
let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)"
if teamsDict[homeKey] == nil {
let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport)
let team = Team(
id: deterministicUUID(from: homeKey),
name: extractTeamName(from: jsonGame.home_team),
abbreviation: jsonGame.home_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.home_team),
stadiumId: stadiumId
)
teamsDict[homeKey] = team
teamsByAbbrev[homeKey] = team
}
// Away team
let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)"
if teamsDict[awayKey] == nil {
// Away teams might not have a stadium in our data yet
let team = Team(
id: deterministicUUID(from: awayKey),
name: extractTeamName(from: jsonGame.away_team),
abbreviation: jsonGame.away_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.away_team),
stadiumId: UUID() // Placeholder, will be updated when they're home team
)
teamsDict[awayKey] = team
teamsByAbbrev[awayKey] = team
}
}
cachedTeams = Array(teamsDict.values)
// Convert games (deduplicate by ID - JSON may have duplicate entries)
var seenGameIds = Set<String>()
let uniqueJsonGames = jsonGames.filter { game in
if seenGameIds.contains(game.id) {
return false
}
seenGameIds.insert(game.id)
return true
}
cachedGames = uniqueJsonGames.compactMap { convertGame($0) }
}
private func loadGamesJSON() throws -> [JSONGame] {
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
return []
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONGame].self, from: data)
}
private func loadStadiumsJSON() throws -> [JSONStadium] {
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
return []
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONStadium].self, from: data)
}
// MARK: - Conversion Helpers
private func convertStadium(_ json: JSONStadium) -> Stadium {
Stadium(
id: deterministicUUID(from: json.id),
name: json.name,
city: json.city,
state: json.state.isEmpty ? stateFromCity(json.city) : json.state,
latitude: json.latitude,
longitude: json.longitude,
capacity: json.capacity,
sport: parseSport(json.sport),
yearOpened: json.year_opened
)
}
private func convertGame(_ json: JSONGame) -> Game? {
let sport = parseSport(json.sport)
let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)"
let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)"
guard let homeTeam = teamsByAbbrev[homeKey],
let awayTeam = teamsByAbbrev[awayKey] else {
return nil
}
let stadiumId = findStadiumId(venue: json.venue, sport: sport)
guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else {
return nil
}
return Game(
id: deterministicUUID(from: json.id),
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: json.season,
isPlayoff: json.is_playoff,
broadcastInfo: json.broadcast
)
}
private func parseSport(_ sport: String) -> Sport {
switch sport.uppercased() {
case "MLB": return .mlb
case "NBA": return .nba
case "NHL": return .nhl
case "NFL": return .nfl
case "MLS": return .mls
default: return .mlb
}
}
private func parseDateTime(date: String, time: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
// Parse date
formatter.dateFormat = "yyyy-MM-dd"
guard let dateOnly = formatter.date(from: date) else { return nil }
// Parse time (e.g., "7:30p", "10:00p", "1:05p")
var hour = 12
var minute = 0
let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "")
let isPM = cleanTime.contains("p")
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
let components = timeWithoutAMPM.split(separator: ":")
if !components.isEmpty, let h = Int(components[0]) {
hour = h
if isPM && hour != 12 {
hour += 12
} else if !isPM && hour == 12 {
hour = 0
}
}
if components.count > 1, let m = Int(components[1]) {
minute = m
}
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
}
// Venue name aliases for stadiums that changed names
private static let venueAliases: [String: String] = [
"daikin park": "minute maid park", // Houston Astros (renamed 2024)
"rate field": "guaranteed rate field", // Chicago White Sox
"george m. steinbrenner field": "tropicana field", // Tampa Bay spring training main stadium
"loandepot park": "loandepot park", // Miami - ensure case match
]
private func findStadiumId(venue: String, sport: Sport) -> UUID {
var venueLower = venue.lowercased()
// Check for known aliases
if let aliasedName = Self.venueAliases[venueLower] {
venueLower = aliasedName
}
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
return stadium.id
}
// Try partial match
for (name, stadium) in stadiumsByVenue {
if name.contains(venueLower) || venueLower.contains(name) {
return stadium.id
}
}
// Generate deterministic ID for unknown venues
return deterministicUUID(from: "venue_\(venue)")
}
private func deterministicUUID(from string: String) -> UUID {
// Create a deterministic UUID using SHA256 (truly deterministic across launches)
let data = Data(string.utf8)
let hash = SHA256.hash(data: data)
let hashBytes = Array(hash)
// Use first 16 bytes of SHA256 hash
var bytes = Array(hashBytes.prefix(16))
// Set UUID version (4) and variant bits
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
return UUID(uuid: (
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11],
bytes[12], bytes[13], bytes[14], bytes[15]
))
}
private func extractTeamName(from fullName: String) -> String {
// "Boston Celtics" -> "Celtics"
let parts = fullName.split(separator: " ")
if parts.count > 1 {
return parts.dropFirst().joined(separator: " ")
}
return fullName
}
private func extractCity(from fullName: String) -> String {
// "Boston Celtics" -> "Boston"
// "New York Knicks" -> "New York"
// "Los Angeles Lakers" -> "Los Angeles"
let knownCities = [
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
"St. Louis", "St Louis"
]
for city in knownCities {
if fullName.hasPrefix(city) {
return city
}
}
// Default: first word
return String(fullName.split(separator: " ").first ?? Substring(fullName))
}
private func stateFromCity(_ city: String) -> String {
let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
]
return cityToState[city] ?? ""
}
}

View File

@@ -5,6 +5,8 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>

View File

@@ -90,13 +90,48 @@ struct BootstrappedContentView: View {
let bootstrapService = BootstrapService()
do {
// 1. Bootstrap from bundled JSON if first launch (no data exists)
try await bootstrapService.bootstrapIfNeeded(context: context)
// 2. Configure DataProvider with SwiftData context
AppDataProvider.shared.configure(with: context)
// 3. Load data from SwiftData into memory
await AppDataProvider.shared.loadInitialData()
// 4. App is now usable
isBootstrapping = false
// 5. Background: Try to refresh from CloudKit (non-blocking)
Task.detached(priority: .background) {
await self.performBackgroundSync(context: context)
}
} catch {
bootstrapError = error
isBootstrapping = false
}
}
@MainActor
private func performBackgroundSync(context: ModelContext) async {
let syncService = CanonicalSyncService()
do {
let result = try await syncService.syncAll(context: context)
// If any data was updated, reload the DataProvider
if !result.isEmpty {
await AppDataProvider.shared.loadInitialData()
print("CloudKit sync completed: \(result.totalUpdated) items updated")
}
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
// Offline or CloudKit not available - silently continue with local data
print("CloudKit unavailable, using local data")
} catch {
// Other sync errors - log but don't interrupt user
print("Background sync error: \(error.localizedDescription)")
}
}
}
// MARK: - Bootstrap Loading View