chore: commit all pending changes

This commit is contained in:
Trey t
2026-02-10 18:15:36 -06:00
parent b993ed3613
commit 53cc532ca9
51 changed files with 2583 additions and 268 deletions

View File

@@ -114,17 +114,33 @@ actor BootstrapService {
// MARK: - Public Methods
/// Bootstrap canonical data from bundled JSON if not already done.
/// Bootstrap canonical data from bundled JSON if not already done,
/// or re-bootstrap if the bundled data schema version has been bumped.
/// This is the main entry point called at app launch.
@MainActor
func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context)
let hasCoreCanonicalData = hasRequiredCanonicalData(context: context)
// Skip if already bootstrapped
// Re-bootstrap if bundled data version is newer (e.g., updated game schedules)
let needsRebootstrap = syncState.bootstrapCompleted && syncState.bundledSchemaVersion < SchemaVersion.current
if needsRebootstrap {
syncState.bootstrapCompleted = false
}
// Recover from corrupted/partial local stores where bootstrap flag is true but core tables are empty.
if syncState.bootstrapCompleted && !hasCoreCanonicalData {
syncState.bootstrapCompleted = false
}
// Skip if already bootstrapped with current schema
guard !syncState.bootstrapCompleted else {
return
}
// Fresh bootstrap should always force a full CloudKit sync baseline.
resetSyncProgress(syncState)
// Clear any partial bootstrap data from a previous failed attempt
try clearCanonicalData(context: context)
@@ -156,6 +172,30 @@ actor BootstrapService {
}
}
@MainActor
private func resetSyncProgress(_ syncState: SyncState) {
syncState.lastSuccessfulSync = nil
syncState.lastSyncAttempt = nil
syncState.lastSyncError = nil
syncState.syncInProgress = false
syncState.syncEnabled = true
syncState.syncPausedReason = nil
syncState.consecutiveFailures = 0
syncState.stadiumChangeToken = nil
syncState.teamChangeToken = nil
syncState.gameChangeToken = nil
syncState.leagueChangeToken = nil
syncState.lastStadiumSync = nil
syncState.lastTeamSync = nil
syncState.lastGameSync = nil
syncState.lastLeagueStructureSync = nil
syncState.lastTeamAliasSync = nil
syncState.lastStadiumAliasSync = nil
syncState.lastSportSync = nil
}
// MARK: - Bootstrap Steps
@MainActor
@@ -188,7 +228,7 @@ actor BootstrapService {
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
imageURL: jsonStadium.image_url,
sport: jsonStadium.sport,
sport: jsonStadium.sport.uppercased(),
timezoneIdentifier: jsonStadium.timezone_identifier
)
context.insert(canonical)
@@ -265,7 +305,7 @@ actor BootstrapService {
let model = LeagueStructureModel(
id: structure.id,
sport: structure.sport,
sport: structure.sport.uppercased(),
structureType: structureType,
name: structure.name,
abbreviation: structure.abbreviation,
@@ -302,7 +342,7 @@ actor BootstrapService {
source: .bundled,
name: jsonTeam.name,
abbreviation: jsonTeam.abbreviation,
sport: jsonTeam.sport,
sport: jsonTeam.sport.uppercased(),
city: jsonTeam.city,
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
conferenceId: jsonTeam.conference_id,
@@ -371,6 +411,8 @@ actor BootstrapService {
}
var seenGameIds = Set<String>()
let teams = (try? context.fetch(FetchDescriptor<CanonicalTeam>())) ?? []
let stadiumByTeamId = Dictionary(uniqueKeysWithValues: teams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
for jsonGame in games {
// Deduplicate
@@ -389,6 +431,21 @@ actor BootstrapService {
guard let dateTime else { continue }
let explicitStadium = jsonGame.stadium_canonical_id?
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let explicitStadium, !explicitStadium.isEmpty {
resolvedStadiumCanonicalId = explicitStadium
} else if let homeStadium = stadiumByTeamId[jsonGame.home_team_canonical_id],
!homeStadium.isEmpty {
resolvedStadiumCanonicalId = homeStadium
} else if let awayStadium = stadiumByTeamId[jsonGame.away_team_canonical_id],
!awayStadium.isEmpty {
resolvedStadiumCanonicalId = awayStadium
} else {
resolvedStadiumCanonicalId = "stadium_placeholder_\(jsonGame.canonical_id)"
}
let game = CanonicalGame(
canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current,
@@ -396,9 +453,9 @@ actor BootstrapService {
source: .bundled,
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
stadiumCanonicalId: jsonGame.stadium_canonical_id ?? "",
stadiumCanonicalId: resolvedStadiumCanonicalId,
dateTime: dateTime,
sport: jsonGame.sport,
sport: jsonGame.sport.uppercased(),
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast_info
@@ -454,6 +511,26 @@ actor BootstrapService {
try context.delete(model: CanonicalSport.self)
}
@MainActor
private func hasRequiredCanonicalData(context: ModelContext) -> Bool {
let stadiumCount = (try? context.fetchCount(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let teamCount = (try? context.fetchCount(
FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let gameCount = (try? context.fetchCount(
FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
return stadiumCount > 0 && teamCount > 0 && gameCount > 0
}
nonisolated private func parseISO8601(_ string: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]