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:
72
CLAUDE.md
72
CLAUDE.md
@@ -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/POISearchService` - Finds nearby restaurants, attractions via MKLocalSearch
|
||||||
- `Services/PDFAssetPrefetcher` - Parallel prefetching of all PDF assets
|
- `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)
|
**CRITICAL: `AppDataProvider.shared` is the ONLY source of truth for canonical data.**
|
||||||
- **SwiftData Local**: User's saved trips, preferences, cached schedules
|
|
||||||
- **No network dependency** for trip planning once schedules are synced
|
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
|
### Key Data Flow
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Usage:
|
|||||||
python cloudkit_import.py --key-id XX --key-file key.p8 # Import all
|
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 --stadiums-only ... # Stadiums first
|
||||||
python cloudkit_import.py --games-only ... # Games after
|
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-all ... # Delete then import
|
||||||
python cloudkit_import.py --delete-only ... # Delete only (no import)
|
python cloudkit_import.py --delete-only ... # Delete only (no import)
|
||||||
"""
|
"""
|
||||||
@@ -44,26 +45,27 @@ def show_menu():
|
|||||||
print("\n" + "="*50)
|
print("\n" + "="*50)
|
||||||
print("CloudKit Import - Select Action")
|
print("CloudKit Import - Select Action")
|
||||||
print("="*50)
|
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(" 2. Stadiums only")
|
||||||
print(" 3. Games only")
|
print(" 3. Games only")
|
||||||
print(" 4. League structure only")
|
print(" 4. League structure only")
|
||||||
print(" 5. Team aliases only")
|
print(" 5. Team aliases only")
|
||||||
print(" 6. Canonical only (league structure + team aliases)")
|
print(" 6. Stadium aliases only")
|
||||||
print(" 7. Delete all then import")
|
print(" 7. Canonical only (league structure + team aliases + stadium aliases)")
|
||||||
print(" 8. Delete only (no import)")
|
print(" 8. Delete all then import")
|
||||||
print(" 9. Dry run (preview only)")
|
print(" 9. Delete only (no import)")
|
||||||
|
print(" 10. Dry run (preview only)")
|
||||||
print(" 0. Exit")
|
print(" 0. Exit")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
choice = input("Enter choice [1-9, 0 to exit]: ").strip()
|
choice = input("Enter choice [1-10, 0 to exit]: ").strip()
|
||||||
if choice == '0':
|
if choice == '0':
|
||||||
return None
|
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)
|
return int(choice)
|
||||||
print("Invalid choice. Please enter 1-9 or 0.")
|
print("Invalid choice. Please enter 1-10 or 0.")
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
print("\nExiting.")
|
print("\nExiting.")
|
||||||
return None
|
return None
|
||||||
@@ -257,7 +259,8 @@ def main():
|
|||||||
p.add_argument('--games-only', action='store_true')
|
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('--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('--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-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('--delete-only', action='store_true', help='Only delete records, do not import')
|
||||||
p.add_argument('--dry-run', action='store_true')
|
p.add_argument('--dry-run', action='store_true')
|
||||||
@@ -268,8 +271,8 @@ def main():
|
|||||||
# Show interactive menu if no action flags provided or --interactive
|
# Show interactive menu if no action flags provided or --interactive
|
||||||
has_action_flag = any([
|
has_action_flag = any([
|
||||||
args.stadiums_only, args.games_only, args.league_structure_only,
|
args.stadiums_only, args.games_only, args.league_structure_only,
|
||||||
args.team_aliases_only, args.canonical_only, args.delete_all,
|
args.team_aliases_only, args.stadium_aliases_only, args.canonical_only,
|
||||||
args.delete_only, args.dry_run
|
args.delete_all, args.delete_only, args.dry_run
|
||||||
])
|
])
|
||||||
|
|
||||||
if args.interactive or not has_action_flag:
|
if args.interactive or not has_action_flag:
|
||||||
@@ -288,13 +291,15 @@ def main():
|
|||||||
args.league_structure_only = True
|
args.league_structure_only = True
|
||||||
elif choice == 5: # Team aliases only
|
elif choice == 5: # Team aliases only
|
||||||
args.team_aliases_only = True
|
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
|
args.canonical_only = True
|
||||||
elif choice == 7: # Delete all then import
|
elif choice == 8: # Delete all then import
|
||||||
args.delete_all = True
|
args.delete_all = True
|
||||||
elif choice == 8: # Delete only
|
elif choice == 9: # Delete only
|
||||||
args.delete_only = True
|
args.delete_only = True
|
||||||
elif choice == 9: # Dry run
|
elif choice == 10: # Dry run
|
||||||
args.dry_run = True
|
args.dry_run = True
|
||||||
|
|
||||||
print(f"\n{'='*50}")
|
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 []
|
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 []
|
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 []
|
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
|
ck = None
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
@@ -325,7 +331,7 @@ def main():
|
|||||||
|
|
||||||
print("--- Deleting Existing Records ---")
|
print("--- Deleting Existing Records ---")
|
||||||
# Delete in order: dependent records first, then base 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...")
|
print(f" Deleting {record_type} records...")
|
||||||
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
||||||
print(f" Deleted {deleted} {record_type} records")
|
print(f" Deleted {deleted} {record_type} records")
|
||||||
@@ -336,15 +342,16 @@ def main():
|
|||||||
print()
|
print()
|
||||||
return
|
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 = {}
|
team_map = {}
|
||||||
|
|
||||||
# Determine what to import based on flags
|
# 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_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.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.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)
|
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)
|
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)
|
# Build stadium UUID lookup (stadium string ID -> UUID)
|
||||||
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
|
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)
|
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"\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:
|
if args.dry_run:
|
||||||
print("[DRY RUN - nothing imported]")
|
print("[DRY RUN - nothing imported]")
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -294,6 +294,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SportsTime/Info.plist;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -328,6 +329,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SportsTime/Info.plist;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum CKRecordType {
|
|||||||
static let sport = "Sport"
|
static let sport = "Sport"
|
||||||
static let leagueStructure = "LeagueStructure"
|
static let leagueStructure = "LeagueStructure"
|
||||||
static let teamAlias = "TeamAlias"
|
static let teamAlias = "TeamAlias"
|
||||||
|
static let stadiumAlias = "StadiumAlias"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CKTeam
|
// 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
|
// MARK: - CKTeamAlias
|
||||||
|
|
||||||
struct CKTeamAlias {
|
struct CKTeamAlias {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,12 +42,13 @@ actor CanonicalSyncService {
|
|||||||
let gamesUpdated: Int
|
let gamesUpdated: Int
|
||||||
let leagueStructuresUpdated: Int
|
let leagueStructuresUpdated: Int
|
||||||
let teamAliasesUpdated: Int
|
let teamAliasesUpdated: Int
|
||||||
|
let stadiumAliasesUpdated: Int
|
||||||
let skippedIncompatible: Int
|
let skippedIncompatible: Int
|
||||||
let skippedOlder: Int
|
let skippedOlder: Int
|
||||||
let duration: TimeInterval
|
let duration: TimeInterval
|
||||||
|
|
||||||
var totalUpdated: Int {
|
var totalUpdated: Int {
|
||||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
|
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmpty: Bool { totalUpdated == 0 }
|
var isEmpty: Bool { totalUpdated == 0 }
|
||||||
@@ -81,7 +82,7 @@ actor CanonicalSyncService {
|
|||||||
guard syncState.syncEnabled else {
|
guard syncState.syncEnabled else {
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
|
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
||||||
skippedIncompatible: 0, skippedOlder: 0,
|
skippedIncompatible: 0, skippedOlder: 0,
|
||||||
duration: 0
|
duration: 0
|
||||||
)
|
)
|
||||||
@@ -101,6 +102,7 @@ actor CanonicalSyncService {
|
|||||||
var totalGames = 0
|
var totalGames = 0
|
||||||
var totalLeagueStructures = 0
|
var totalLeagueStructures = 0
|
||||||
var totalTeamAliases = 0
|
var totalTeamAliases = 0
|
||||||
|
var totalStadiumAliases = 0
|
||||||
var totalSkippedIncompatible = 0
|
var totalSkippedIncompatible = 0
|
||||||
var totalSkippedOlder = 0
|
var totalSkippedOlder = 0
|
||||||
|
|
||||||
@@ -138,13 +140,21 @@ actor CanonicalSyncService {
|
|||||||
totalSkippedIncompatible += skipIncompat4
|
totalSkippedIncompatible += skipIncompat4
|
||||||
totalSkippedOlder += skipOlder4
|
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,
|
context: context,
|
||||||
since: syncState.lastSuccessfulSync
|
since: syncState.lastSuccessfulSync
|
||||||
)
|
)
|
||||||
totalGames = games
|
totalGames = games
|
||||||
totalSkippedIncompatible += skipIncompat5
|
totalSkippedIncompatible += skipIncompat6
|
||||||
totalSkippedOlder += skipOlder5
|
totalSkippedOlder += skipOlder6
|
||||||
|
|
||||||
// Mark sync successful
|
// Mark sync successful
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
@@ -176,6 +186,7 @@ actor CanonicalSyncService {
|
|||||||
gamesUpdated: totalGames,
|
gamesUpdated: totalGames,
|
||||||
leagueStructuresUpdated: totalLeagueStructures,
|
leagueStructuresUpdated: totalLeagueStructures,
|
||||||
teamAliasesUpdated: totalTeamAliases,
|
teamAliasesUpdated: totalTeamAliases,
|
||||||
|
stadiumAliasesUpdated: totalStadiumAliases,
|
||||||
skippedIncompatible: totalSkippedIncompatible,
|
skippedIncompatible: totalSkippedIncompatible,
|
||||||
skippedOlder: totalSkippedOlder,
|
skippedOlder: totalSkippedOlder,
|
||||||
duration: Date().timeIntervalSince(startTime)
|
duration: Date().timeIntervalSince(startTime)
|
||||||
@@ -346,6 +357,30 @@ actor CanonicalSyncService {
|
|||||||
return (updated, skippedIncompatible, skippedOlder)
|
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
|
// MARK: - Merge Logic
|
||||||
|
|
||||||
private enum MergeResult {
|
private enum MergeResult {
|
||||||
@@ -631,4 +666,41 @@ actor CanonicalSyncService {
|
|||||||
return .applied
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
// MARK: - Delta Sync (Date-Based for Public Database)
|
||||||
|
|
||||||
/// Fetch league structure records modified after the given date
|
/// 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
|
// MARK: - Sync Status
|
||||||
|
|
||||||
func checkAccountStatus() async -> CKAccountStatus {
|
func checkAccountStatus() async -> CKAccountStatus {
|
||||||
@@ -327,10 +365,26 @@ actor CloudKitService {
|
|||||||
try await publicDatabase.save(subscription)
|
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
|
/// Subscribe to all canonical data updates
|
||||||
func subscribeToAllUpdates() async throws {
|
func subscribeToAllUpdates() async throws {
|
||||||
try await subscribeToScheduleUpdates()
|
try await subscribeToScheduleUpdates()
|
||||||
try await subscribeToLeagueStructureUpdates()
|
try await subscribeToLeagueStructureUpdates()
|
||||||
try await subscribeToTeamAliasUpdates()
|
try await subscribeToTeamAliasUpdates()
|
||||||
|
try await subscribeToStadiumAliasUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,61 +2,90 @@
|
|||||||
// DataProvider.swift
|
// DataProvider.swift
|
||||||
// SportsTime
|
// 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 Foundation
|
||||||
|
import SwiftData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Protocol defining data operations for teams, stadiums, and games
|
/// Environment-aware data provider that reads from SwiftData (offline-first)
|
||||||
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
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppDataProvider: ObservableObject {
|
final class AppDataProvider: ObservableObject {
|
||||||
static let shared = AppDataProvider()
|
static let shared = AppDataProvider()
|
||||||
|
|
||||||
private let provider: any DataProvider
|
|
||||||
|
|
||||||
@Published private(set) var teams: [Team] = []
|
@Published private(set) var teams: [Team] = []
|
||||||
@Published private(set) var stadiums: [Stadium] = []
|
@Published private(set) var stadiums: [Stadium] = []
|
||||||
@Published private(set) var isLoading = false
|
@Published private(set) var isLoading = false
|
||||||
@Published private(set) var error: Error?
|
@Published private(set) var error: Error?
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
@Published private(set) var isUsingStubData: Bool
|
|
||||||
|
|
||||||
private var teamsById: [UUID: Team] = [:]
|
private var teamsById: [UUID: Team] = [:]
|
||||||
private var stadiumsById: [UUID: Stadium] = [:]
|
private var stadiumsById: [UUID: Stadium] = [:]
|
||||||
|
private var stadiumsByCanonicalId: [String: Stadium] = [:]
|
||||||
|
private var teamsByCanonicalId: [String: Team] = [:]
|
||||||
|
|
||||||
private init() {
|
// Canonical ID lookups for game conversion
|
||||||
#if targetEnvironment(simulator)
|
private var canonicalTeamUUIDs: [String: UUID] = [:]
|
||||||
self.provider = StubDataProvider()
|
private var canonicalStadiumUUIDs: [String: UUID] = [:]
|
||||||
self.isUsingStubData = true
|
|
||||||
#else
|
private var modelContext: ModelContext?
|
||||||
self.provider = CloudKitDataProvider()
|
|
||||||
self.isUsingStubData = false
|
private init() {}
|
||||||
#endif
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Set the model context for SwiftData access
|
||||||
|
func configure(with context: ModelContext) {
|
||||||
|
self.modelContext = context
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Loading
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
/// Load all data from SwiftData into memory for fast access
|
||||||
func loadInitialData() async {
|
func loadInitialData() async {
|
||||||
|
guard let context = modelContext else {
|
||||||
|
errorMessage = "Model context not configured"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let teamsTask = provider.fetchAllTeams()
|
// Fetch canonical stadiums from SwiftData
|
||||||
async let stadiumsTask = provider.fetchStadiums()
|
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.teams = loadedTeams
|
||||||
self.stadiums = loadedStadiums
|
self.stadiums = loadedStadiums
|
||||||
@@ -64,9 +93,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
// Build lookup dictionaries
|
// Build lookup dictionaries
|
||||||
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
|
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
|
||||||
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.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 {
|
} catch {
|
||||||
self.error = error
|
self.error = error
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -98,12 +125,71 @@ final class AppDataProvider: ObservableObject {
|
|||||||
teams.filter { $0.sport == sport }
|
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] {
|
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] {
|
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
|
return games.compactMap { game in
|
||||||
guard let homeTeam = teamsById[game.homeTeamId],
|
guard let homeTeam = teamsById[game.homeTeamId],
|
||||||
@@ -124,3 +210,16 @@ final class AppDataProvider: ObservableObject {
|
|||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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] ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -90,13 +90,48 @@ struct BootstrappedContentView: View {
|
|||||||
let bootstrapService = BootstrapService()
|
let bootstrapService = BootstrapService()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// 1. Bootstrap from bundled JSON if first launch (no data exists)
|
||||||
try await bootstrapService.bootstrapIfNeeded(context: context)
|
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
|
isBootstrapping = false
|
||||||
|
|
||||||
|
// 5. Background: Try to refresh from CloudKit (non-blocking)
|
||||||
|
Task.detached(priority: .background) {
|
||||||
|
await self.performBackgroundSync(context: context)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
bootstrapError = error
|
bootstrapError = error
|
||||||
isBootstrapping = false
|
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
|
// MARK: - Bootstrap Loading View
|
||||||
|
|||||||
Reference in New Issue
Block a user