chore: commit all pending changes
This commit is contained in:
117
SportsTime/Configuration.storekit
Normal file
117
SportsTime/Configuration.storekit
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "B799FEB8",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6758580420",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "QND55P4443",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 792371705.12238097,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21914326",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "SportsTime Pro",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "9.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "6758582806",
|
||||
"introductoryOffer" : {
|
||||
"internalID" : "24222FB4",
|
||||
"numberOfPeriods" : 1,
|
||||
"paymentMode" : "free",
|
||||
"subscriptionPeriod" : "P2W"
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlimited trips, PDF export, and progress tracking",
|
||||
"displayName" : "Yearly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.88oakapps.SportsTime.pro.annual2",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "Pro Annual",
|
||||
"subscriptionGroupID" : "21914326",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6758580148",
|
||||
"introductoryOffer" : {
|
||||
"internalID" : "D306DB54",
|
||||
"numberOfPeriods" : 1,
|
||||
"paymentMode" : "free",
|
||||
"subscriptionPeriod" : "P2W"
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlimited trips, PDF export, and progress tracking",
|
||||
"displayName" : "Monthly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.88oakapps.SportsTime.pro.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Pro Monthly",
|
||||
"subscriptionGroupID" : "21914326",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,13 @@ import CloudKit
|
||||
|
||||
// MARK: - Record Type Constants
|
||||
|
||||
enum CKRecordType {
|
||||
private extension String {
|
||||
nonisolated var ckTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum CKRecordType {
|
||||
static let team = "Team"
|
||||
static let stadium = "Stadium"
|
||||
static let game = "Game"
|
||||
@@ -25,7 +31,7 @@ enum CKRecordType {
|
||||
|
||||
// MARK: - CKTeam
|
||||
|
||||
struct CKTeam {
|
||||
nonisolated struct CKTeam {
|
||||
static let idKey = "teamId"
|
||||
static let canonicalIdKey = "canonicalId"
|
||||
static let nameKey = "name"
|
||||
@@ -62,12 +68,22 @@ struct CKTeam {
|
||||
|
||||
/// The canonical ID string from CloudKit (e.g., "team_nba_atl")
|
||||
var canonicalId: String? {
|
||||
record[CKTeam.canonicalIdKey] as? String
|
||||
if let explicit = (record[CKTeam.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
let fallback = record.recordID.recordName.ckTrimmed
|
||||
return fallback.isEmpty ? nil : fallback
|
||||
}
|
||||
|
||||
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
|
||||
var stadiumCanonicalId: String? {
|
||||
record[CKTeam.stadiumCanonicalIdKey] as? String
|
||||
if let explicit = (record[CKTeam.stadiumCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
if let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference {
|
||||
return stadiumRef.recordID.recordName.ckTrimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The conference canonical ID string from CloudKit (e.g., "nba_eastern")
|
||||
@@ -82,16 +98,18 @@ struct CKTeam {
|
||||
|
||||
var team: Team? {
|
||||
// Use teamId field, or fall back to record name
|
||||
let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
|
||||
let id = ((record[CKTeam.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
let abbreviation = (record[CKTeam.abbreviationKey] as? String)?.ckTrimmed
|
||||
let sportRaw = (record[CKTeam.sportKey] as? String)?.ckTrimmed
|
||||
let city = (record[CKTeam.cityKey] as? String)?.ckTrimmed
|
||||
guard !id.isEmpty,
|
||||
let abbreviation = record[CKTeam.abbreviationKey] as? String,
|
||||
let sportRaw = record[CKTeam.sportKey] as? String,
|
||||
let sport = Sport(rawValue: sportRaw),
|
||||
let city = record[CKTeam.cityKey] as? String
|
||||
let abbreviation, !abbreviation.isEmpty,
|
||||
let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased()),
|
||||
let city, !city.isEmpty
|
||||
else { return nil }
|
||||
|
||||
// Name defaults to abbreviation if not provided
|
||||
let name = record[CKTeam.nameKey] as? String ?? abbreviation
|
||||
let name = ((record[CKTeam.nameKey] as? String)?.ckTrimmed).flatMap { $0.isEmpty ? nil : $0 } ?? abbreviation
|
||||
|
||||
// Stadium reference is optional - use placeholder string if not present
|
||||
let stadiumId: String
|
||||
@@ -122,7 +140,7 @@ struct CKTeam {
|
||||
|
||||
// MARK: - CKStadium
|
||||
|
||||
struct CKStadium {
|
||||
nonisolated struct CKStadium {
|
||||
static let idKey = "stadiumId"
|
||||
static let canonicalIdKey = "canonicalId"
|
||||
static let nameKey = "name"
|
||||
@@ -157,25 +175,31 @@ struct CKStadium {
|
||||
|
||||
/// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
|
||||
var canonicalId: String? {
|
||||
record[CKStadium.canonicalIdKey] as? String
|
||||
if let explicit = (record[CKStadium.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
let fallback = record.recordID.recordName.ckTrimmed
|
||||
return fallback.isEmpty ? nil : fallback
|
||||
}
|
||||
|
||||
var stadium: Stadium? {
|
||||
// Use stadiumId field, or fall back to record name
|
||||
let id = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
|
||||
let id = ((record[CKStadium.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
let name = (record[CKStadium.nameKey] as? String)?.ckTrimmed
|
||||
let city = (record[CKStadium.cityKey] as? String)?.ckTrimmed
|
||||
guard !id.isEmpty,
|
||||
let name = record[CKStadium.nameKey] as? String,
|
||||
let city = record[CKStadium.cityKey] as? String
|
||||
let name, !name.isEmpty,
|
||||
let city, !city.isEmpty
|
||||
else { return nil }
|
||||
|
||||
// These fields are optional in CloudKit
|
||||
let state = record[CKStadium.stateKey] as? String ?? ""
|
||||
let state = ((record[CKStadium.stateKey] as? String)?.ckTrimmed) ?? ""
|
||||
let location = record[CKStadium.locationKey] as? CLLocation
|
||||
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
||||
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
||||
let sportRaw = record[CKStadium.sportKey] as? String ?? "MLB"
|
||||
let sport = Sport(rawValue: sportRaw) ?? .mlb
|
||||
let timezoneIdentifier = record[CKStadium.timezoneIdentifierKey] as? String
|
||||
let sportRaw = ((record[CKStadium.sportKey] as? String)?.ckTrimmed).flatMap { $0.isEmpty ? nil : $0 } ?? "MLB"
|
||||
let sport = Sport(rawValue: sportRaw.uppercased()) ?? .mlb
|
||||
let timezoneIdentifier = (record[CKStadium.timezoneIdentifierKey] as? String)?.ckTrimmed
|
||||
|
||||
return Stadium(
|
||||
id: id,
|
||||
@@ -195,7 +219,7 @@ struct CKStadium {
|
||||
|
||||
// MARK: - CKGame
|
||||
|
||||
struct CKGame {
|
||||
nonisolated struct CKGame {
|
||||
static let idKey = "gameId"
|
||||
static let canonicalIdKey = "canonicalId"
|
||||
static let homeTeamRefKey = "homeTeamRef"
|
||||
@@ -232,31 +256,64 @@ struct CKGame {
|
||||
|
||||
/// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc")
|
||||
var canonicalId: String? {
|
||||
record[CKGame.canonicalIdKey] as? String
|
||||
if let explicit = (record[CKGame.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
let fallback = record.recordID.recordName.ckTrimmed
|
||||
return fallback.isEmpty ? nil : fallback
|
||||
}
|
||||
|
||||
/// The home team canonical ID string from CloudKit (e.g., "team_nba_okc")
|
||||
var homeTeamCanonicalId: String? {
|
||||
record[CKGame.homeTeamCanonicalIdKey] as? String
|
||||
if let explicit = (record[CKGame.homeTeamCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
if let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference {
|
||||
return homeRef.recordID.recordName.ckTrimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The away team canonical ID string from CloudKit (e.g., "team_nba_hou")
|
||||
var awayTeamCanonicalId: String? {
|
||||
record[CKGame.awayTeamCanonicalIdKey] as? String
|
||||
if let explicit = (record[CKGame.awayTeamCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
if let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference {
|
||||
return awayRef.recordID.recordName.ckTrimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_paycom_center")
|
||||
var stadiumCanonicalId: String? {
|
||||
record[CKGame.stadiumCanonicalIdKey] as? String
|
||||
if let explicit = (record[CKGame.stadiumCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
|
||||
return explicit
|
||||
}
|
||||
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
|
||||
return stadiumRef.recordID.recordName.ckTrimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func game(homeTeamId: String, awayTeamId: String, stadiumId: String) -> Game? {
|
||||
let id = (record[CKGame.idKey] as? String) ?? record.recordID.recordName
|
||||
let id = ((record[CKGame.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
let sportRaw = (record[CKGame.sportKey] as? String)?.ckTrimmed
|
||||
let season: String
|
||||
if let seasonString = (record[CKGame.seasonKey] as? String)?.ckTrimmed, !seasonString.isEmpty {
|
||||
season = seasonString
|
||||
} else if let seasonInt = record[CKGame.seasonKey] as? Int {
|
||||
season = String(seasonInt)
|
||||
} else if let seasonInt64 = record[CKGame.seasonKey] as? Int64 {
|
||||
season = String(seasonInt64)
|
||||
} else if let seasonNumber = record[CKGame.seasonKey] as? NSNumber {
|
||||
season = seasonNumber.stringValue
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
guard !id.isEmpty,
|
||||
let dateTime = record[CKGame.dateTimeKey] as? Date,
|
||||
let sportRaw = record[CKGame.sportKey] as? String,
|
||||
let sport = Sport(rawValue: sportRaw),
|
||||
let season = record[CKGame.seasonKey] as? String
|
||||
let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased())
|
||||
else { return nil }
|
||||
|
||||
return Game(
|
||||
@@ -267,7 +324,13 @@ struct CKGame {
|
||||
dateTime: dateTime,
|
||||
sport: sport,
|
||||
season: season,
|
||||
isPlayoff: (record[CKGame.isPlayoffKey] as? Int) == 1,
|
||||
isPlayoff: {
|
||||
if let value = record[CKGame.isPlayoffKey] as? Int { return value == 1 }
|
||||
if let value = record[CKGame.isPlayoffKey] as? Int64 { return value == 1 }
|
||||
if let value = record[CKGame.isPlayoffKey] as? NSNumber { return value.intValue == 1 }
|
||||
if let value = record[CKGame.isPlayoffKey] as? Bool { return value }
|
||||
return false
|
||||
}(),
|
||||
broadcastInfo: record[CKGame.broadcastInfoKey] as? String
|
||||
)
|
||||
}
|
||||
@@ -275,7 +338,7 @@ struct CKGame {
|
||||
|
||||
// MARK: - CKLeagueStructure
|
||||
|
||||
struct CKLeagueStructure {
|
||||
nonisolated struct CKLeagueStructure {
|
||||
static let idKey = "structureId"
|
||||
static let sportKey = "sport"
|
||||
static let typeKey = "type"
|
||||
@@ -308,15 +371,18 @@ struct CKLeagueStructure {
|
||||
|
||||
/// Convert to SwiftData model for local storage
|
||||
func toModel() -> LeagueStructureModel? {
|
||||
guard let id = record[CKLeagueStructure.idKey] as? String,
|
||||
let sport = record[CKLeagueStructure.sportKey] as? String,
|
||||
let id = ((record[CKLeagueStructure.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
let sport = (record[CKLeagueStructure.sportKey] as? String)?.ckTrimmed.uppercased()
|
||||
guard !id.isEmpty,
|
||||
let sport, !sport.isEmpty,
|
||||
let typeRaw = record[CKLeagueStructure.typeKey] as? String,
|
||||
let structureType = LeagueStructureType(rawValue: typeRaw),
|
||||
let name = record[CKLeagueStructure.nameKey] as? String
|
||||
let structureType = LeagueStructureType(rawValue: typeRaw.lowercased()),
|
||||
let name = (record[CKLeagueStructure.nameKey] as? String)?.ckTrimmed,
|
||||
!name.isEmpty
|
||||
else { return nil }
|
||||
|
||||
let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String
|
||||
let parentId = record[CKLeagueStructure.parentIdKey] as? String
|
||||
let abbreviation = (record[CKLeagueStructure.abbreviationKey] as? String)?.ckTrimmed
|
||||
let parentId = (record[CKLeagueStructure.parentIdKey] as? String)?.ckTrimmed
|
||||
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
|
||||
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||
@@ -337,7 +403,7 @@ struct CKLeagueStructure {
|
||||
|
||||
// MARK: - CKSport
|
||||
|
||||
struct CKSport {
|
||||
nonisolated struct CKSport {
|
||||
static let idKey = "sportId"
|
||||
static let abbreviationKey = "abbreviation"
|
||||
static let displayNameKey = "displayName"
|
||||
@@ -357,16 +423,49 @@ struct CKSport {
|
||||
|
||||
/// Convert to CanonicalSport for local storage
|
||||
func toCanonical() -> CanonicalSport? {
|
||||
guard let id = record[CKSport.idKey] as? String,
|
||||
let abbreviation = record[CKSport.abbreviationKey] as? String,
|
||||
let displayName = record[CKSport.displayNameKey] as? String,
|
||||
let iconName = record[CKSport.iconNameKey] as? String,
|
||||
let colorHex = record[CKSport.colorHexKey] as? String,
|
||||
let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int,
|
||||
let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int
|
||||
let id = ((record[CKSport.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
let seasonStartMonth: Int?
|
||||
if let intValue = record[CKSport.seasonStartMonthKey] as? Int {
|
||||
seasonStartMonth = intValue
|
||||
} else if let int64Value = record[CKSport.seasonStartMonthKey] as? Int64 {
|
||||
seasonStartMonth = Int(int64Value)
|
||||
} else if let numberValue = record[CKSport.seasonStartMonthKey] as? NSNumber {
|
||||
seasonStartMonth = numberValue.intValue
|
||||
} else {
|
||||
seasonStartMonth = nil
|
||||
}
|
||||
|
||||
let seasonEndMonth: Int?
|
||||
if let intValue = record[CKSport.seasonEndMonthKey] as? Int {
|
||||
seasonEndMonth = intValue
|
||||
} else if let int64Value = record[CKSport.seasonEndMonthKey] as? Int64 {
|
||||
seasonEndMonth = Int(int64Value)
|
||||
} else if let numberValue = record[CKSport.seasonEndMonthKey] as? NSNumber {
|
||||
seasonEndMonth = numberValue.intValue
|
||||
} else {
|
||||
seasonEndMonth = nil
|
||||
}
|
||||
|
||||
guard !id.isEmpty,
|
||||
let abbreviation = (record[CKSport.abbreviationKey] as? String)?.ckTrimmed,
|
||||
!abbreviation.isEmpty,
|
||||
let displayName = (record[CKSport.displayNameKey] as? String)?.ckTrimmed,
|
||||
!displayName.isEmpty,
|
||||
let iconName = (record[CKSport.iconNameKey] as? String)?.ckTrimmed,
|
||||
!iconName.isEmpty,
|
||||
let colorHex = (record[CKSport.colorHexKey] as? String)?.ckTrimmed,
|
||||
!colorHex.isEmpty,
|
||||
let seasonStartMonth,
|
||||
let seasonEndMonth
|
||||
else { return nil }
|
||||
|
||||
let isActive = (record[CKSport.isActiveKey] as? Int ?? 1) == 1
|
||||
let isActive: Bool = {
|
||||
if let value = record[CKSport.isActiveKey] as? Int { return value == 1 }
|
||||
if let value = record[CKSport.isActiveKey] as? Int64 { return value == 1 }
|
||||
if let value = record[CKSport.isActiveKey] as? NSNumber { return value.intValue == 1 }
|
||||
if let value = record[CKSport.isActiveKey] as? Bool { return value }
|
||||
return true
|
||||
}()
|
||||
let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||
let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||
|
||||
@@ -388,7 +487,7 @@ struct CKSport {
|
||||
|
||||
// MARK: - CKStadiumAlias
|
||||
|
||||
struct CKStadiumAlias {
|
||||
nonisolated struct CKStadiumAlias {
|
||||
static let aliasNameKey = "aliasName"
|
||||
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
|
||||
static let validFromKey = "validFrom"
|
||||
@@ -415,8 +514,10 @@ struct CKStadiumAlias {
|
||||
|
||||
/// 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
|
||||
let aliasName = ((record[CKStadiumAlias.aliasNameKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
guard !aliasName.isEmpty,
|
||||
let stadiumCanonicalId = (record[CKStadiumAlias.stadiumCanonicalIdKey] as? String)?.ckTrimmed,
|
||||
!stadiumCanonicalId.isEmpty
|
||||
else { return nil }
|
||||
|
||||
let validFrom = record[CKStadiumAlias.validFromKey] as? Date
|
||||
@@ -437,7 +538,7 @@ struct CKStadiumAlias {
|
||||
|
||||
// MARK: - CKTeamAlias
|
||||
|
||||
struct CKTeamAlias {
|
||||
nonisolated struct CKTeamAlias {
|
||||
static let idKey = "aliasId"
|
||||
static let teamCanonicalIdKey = "teamCanonicalId"
|
||||
static let aliasTypeKey = "aliasType"
|
||||
@@ -468,11 +569,14 @@ struct CKTeamAlias {
|
||||
|
||||
/// Convert to SwiftData model for local storage
|
||||
func toModel() -> TeamAlias? {
|
||||
guard let id = record[CKTeamAlias.idKey] as? String,
|
||||
let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String,
|
||||
let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String,
|
||||
let aliasType = TeamAliasType(rawValue: aliasTypeRaw),
|
||||
let aliasValue = record[CKTeamAlias.aliasValueKey] as? String
|
||||
let id = ((record[CKTeamAlias.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||
guard !id.isEmpty,
|
||||
let teamCanonicalId = (record[CKTeamAlias.teamCanonicalIdKey] as? String)?.ckTrimmed,
|
||||
!teamCanonicalId.isEmpty,
|
||||
let aliasTypeRaw = (record[CKTeamAlias.aliasTypeKey] as? String)?.ckTrimmed,
|
||||
let aliasType = TeamAliasType(rawValue: aliasTypeRaw.lowercased()),
|
||||
let aliasValue = (record[CKTeamAlias.aliasValueKey] as? String)?.ckTrimmed,
|
||||
!aliasValue.isEmpty
|
||||
else { return nil }
|
||||
|
||||
let validFrom = record[CKTeamAlias.validFromKey] as? Date
|
||||
@@ -495,7 +599,7 @@ struct CKTeamAlias {
|
||||
|
||||
// MARK: - CKTripPoll
|
||||
|
||||
struct CKTripPoll {
|
||||
nonisolated struct CKTripPoll {
|
||||
static let pollIdKey = "pollId"
|
||||
static let titleKey = "title"
|
||||
static let ownerIdKey = "ownerId"
|
||||
@@ -594,7 +698,7 @@ struct CKTripPoll {
|
||||
|
||||
// MARK: - CKPollVote
|
||||
|
||||
struct CKPollVote {
|
||||
nonisolated struct CKPollVote {
|
||||
static let voteIdKey = "voteId"
|
||||
static let pollIdKey = "pollId"
|
||||
static let voterIdKey = "voterId"
|
||||
@@ -640,4 +744,3 @@ struct CKPollVote {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import CryptoKit
|
||||
/// Schema version constants for canonical data models.
|
||||
/// Marked nonisolated to allow access from any isolation domain.
|
||||
nonisolated enum SchemaVersion {
|
||||
static let current: Int = 1
|
||||
static let current: Int = 2
|
||||
static let minimumSupported: Int = 1
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,27 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
// Handle CloudKit subscription notification
|
||||
// TODO: Re-implement subscription handling for ItineraryItem changes
|
||||
completionHandler(.noData)
|
||||
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) as? CKQueryNotification,
|
||||
let subscriptionID = notification.subscriptionID,
|
||||
CloudKitService.canonicalSubscriptionIDs.contains(subscriptionID),
|
||||
let recordType = CloudKitService.recordType(forSubscriptionID: subscriptionID) else {
|
||||
completionHandler(.noData)
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
var changed = false
|
||||
|
||||
if notification.queryNotificationReason == .recordDeleted,
|
||||
let recordID = notification.recordID {
|
||||
changed = await BackgroundSyncManager.shared.applyDeletionHint(
|
||||
recordType: recordType,
|
||||
recordName: recordID.recordName
|
||||
)
|
||||
}
|
||||
|
||||
let updated = await BackgroundSyncManager.shared.triggerSyncFromPushNotification(subscriptionID: subscriptionID)
|
||||
completionHandler((changed || updated) ? .newData : .noData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ import os
|
||||
@MainActor
|
||||
final class BackgroundSyncManager {
|
||||
|
||||
enum SyncTriggerError: Error, LocalizedError {
|
||||
case modelContainerNotConfigured
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelContainerNotConfigured:
|
||||
return "Sync is unavailable because the model container is not configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Identifiers
|
||||
|
||||
/// Background app refresh task - runs periodically (system decides frequency)
|
||||
@@ -163,7 +174,7 @@ final class BackgroundSyncManager {
|
||||
task.setTaskCompleted(success: true) // Partial success - progress was saved
|
||||
} else {
|
||||
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
|
||||
task.setTaskCompleted(success: !result.isEmpty)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
} catch {
|
||||
currentCancellationToken = nil
|
||||
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
|
||||
task.setTaskCompleted(success: true) // Partial success
|
||||
} else {
|
||||
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
|
||||
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
} catch {
|
||||
currentCancellationToken = nil
|
||||
@@ -291,6 +302,191 @@ final class BackgroundSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger sync from a CloudKit push notification.
|
||||
/// Returns true if local data changed and UI should refresh.
|
||||
@MainActor
|
||||
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
|
||||
guard let container = modelContainer else {
|
||||
logger.error("Push sync: No model container configured")
|
||||
return false
|
||||
}
|
||||
|
||||
if let subscriptionID {
|
||||
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
|
||||
} else {
|
||||
logger.info("Triggering sync from CloudKit push")
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await performSync(context: container.mainContext)
|
||||
return !result.isEmpty || result.wasCancelled
|
||||
} catch {
|
||||
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a targeted local deletion for records removed remotely.
|
||||
/// This covers deletions which are not returned by date-based delta queries.
|
||||
@MainActor
|
||||
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
|
||||
guard let container = modelContainer else {
|
||||
logger.error("Deletion hint: No model container configured")
|
||||
return false
|
||||
}
|
||||
|
||||
let context = container.mainContext
|
||||
var changed = false
|
||||
|
||||
do {
|
||||
switch recordType {
|
||||
case CKRecordType.game:
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.canonicalId == recordName && game.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let game = try context.fetch(descriptor).first {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.team:
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate<CanonicalTeam> { team in
|
||||
team.canonicalId == recordName && team.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let team = try context.fetch(descriptor).first {
|
||||
team.deprecatedAt = Date()
|
||||
team.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Avoid dangling schedule entries when a referenced team disappears.
|
||||
let impactedGames = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.deprecatedAt == nil &&
|
||||
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
|
||||
}
|
||||
)
|
||||
for game in try context.fetch(impactedGames) {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Dependent team deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.stadium:
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
if let stadium = try context.fetch(descriptor).first {
|
||||
stadium.deprecatedAt = Date()
|
||||
stadium.deprecationReason = "Deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Avoid dangling schedule entries when a referenced stadium disappears.
|
||||
let impactedGames = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
|
||||
}
|
||||
)
|
||||
for game in try context.fetch(impactedGames) {
|
||||
game.deprecatedAt = Date()
|
||||
game.deprecationReason = "Dependent stadium deleted in CloudKit"
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.leagueStructure:
|
||||
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
||||
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.teamAlias:
|
||||
let descriptor = FetchDescriptor<TeamAlias>(
|
||||
predicate: #Predicate<TeamAlias> { $0.id == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.stadiumAlias:
|
||||
let descriptor = FetchDescriptor<StadiumAlias>(
|
||||
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
|
||||
)
|
||||
let records = try context.fetch(descriptor)
|
||||
for record in records {
|
||||
context.delete(record)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case CKRecordType.sport:
|
||||
let descriptor = FetchDescriptor<CanonicalSport>(
|
||||
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
|
||||
)
|
||||
if let sport = try context.fetch(descriptor).first, sport.isActive {
|
||||
sport.isActive = false
|
||||
sport.lastModified = Date()
|
||||
changed = true
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard changed else { return false }
|
||||
|
||||
try context.save()
|
||||
await AppDataProvider.shared.loadInitialData()
|
||||
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
|
||||
return true
|
||||
|
||||
} catch {
|
||||
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual full sync from settings or other explicit user action.
|
||||
@MainActor
|
||||
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
|
||||
guard let container = modelContainer else {
|
||||
throw SyncTriggerError.modelContainerNotConfigured
|
||||
}
|
||||
|
||||
let context = container.mainContext
|
||||
let syncService = CanonicalSyncService()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
if !syncState.syncEnabled {
|
||||
syncService.resumeSync(context: context)
|
||||
}
|
||||
|
||||
return try await performSync(context: context)
|
||||
}
|
||||
|
||||
/// Register CloudKit subscriptions for canonical data updates.
|
||||
@MainActor
|
||||
func ensureCanonicalSubscriptions() async {
|
||||
do {
|
||||
try await CloudKitService.shared.subscribeToAllUpdates()
|
||||
logger.info("CloudKit subscriptions ensured for canonical sync")
|
||||
} catch {
|
||||
logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up old game data (older than 1 year).
|
||||
@MainActor
|
||||
private func performCleanup(context: ModelContext) async -> Bool {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -78,15 +78,17 @@ actor CanonicalSyncService {
|
||||
let startTime = Date()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
// Prevent concurrent syncs
|
||||
guard !syncState.syncInProgress else {
|
||||
throw SyncError.syncAlreadyInProgress
|
||||
// Prevent concurrent syncs, but auto-heal stale "in progress" state.
|
||||
if syncState.syncInProgress {
|
||||
let staleSyncTimeout: TimeInterval = 15 * 60
|
||||
if let lastAttempt = syncState.lastSyncAttempt,
|
||||
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
|
||||
throw SyncError.syncAlreadyInProgress
|
||||
}
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag")
|
||||
syncState.syncInProgress = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.syncStarted()
|
||||
#endif
|
||||
|
||||
// Check if sync is enabled
|
||||
guard syncState.syncEnabled else {
|
||||
return SyncResult(
|
||||
@@ -102,6 +104,10 @@ actor CanonicalSyncService {
|
||||
throw SyncError.cloudKitUnavailable
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.syncStarted()
|
||||
#endif
|
||||
|
||||
// Mark sync in progress
|
||||
syncState.syncInProgress = true
|
||||
syncState.lastSyncAttempt = Date()
|
||||
@@ -115,7 +121,7 @@ actor CanonicalSyncService {
|
||||
var totalSports = 0
|
||||
var totalSkippedIncompatible = 0
|
||||
var totalSkippedOlder = 0
|
||||
var wasCancelled = false
|
||||
var nonCriticalErrors: [String] = []
|
||||
|
||||
/// Helper to save partial progress and check cancellation
|
||||
func saveProgressAndCheckCancellation() throws -> Bool {
|
||||
@@ -129,41 +135,49 @@ actor CanonicalSyncService {
|
||||
do {
|
||||
// Sync in dependency order, checking cancellation between each entity type
|
||||
|
||||
// Stadium sync
|
||||
// Sport sync (non-critical for schedule rendering)
|
||||
var entityStartTime = Date()
|
||||
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
||||
do {
|
||||
let (sports, skipIncompat1, skipOlder1) = try await syncSports(
|
||||
context: context,
|
||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalSports = sports
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
syncState.lastSportSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("Sport: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Stadium sync
|
||||
entityStartTime = Date()
|
||||
let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums(
|
||||
context: context,
|
||||
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiums = stadiums
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
syncState.lastStadiumSync = Date()
|
||||
totalSkippedIncompatible += skipIncompat2
|
||||
totalSkippedOlder += skipOlder2
|
||||
syncState.lastStadiumSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// League Structure sync
|
||||
entityStartTime = Date()
|
||||
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
||||
context: context,
|
||||
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalLeagueStructures = leagueStructures
|
||||
totalSkippedIncompatible += skipIncompat2
|
||||
totalSkippedOlder += skipOlder2
|
||||
syncState.lastLeagueStructureSync = Date()
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
@@ -177,92 +191,119 @@ actor CanonicalSyncService {
|
||||
totalTeams = teams
|
||||
totalSkippedIncompatible += skipIncompat3
|
||||
totalSkippedOlder += skipOlder3
|
||||
syncState.lastTeamSync = Date()
|
||||
syncState.lastTeamSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Team Alias sync
|
||||
entityStartTime = Date()
|
||||
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
||||
context: context,
|
||||
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalTeamAliases = teamAliases
|
||||
totalSkippedIncompatible += skipIncompat4
|
||||
totalSkippedOlder += skipOlder4
|
||||
syncState.lastTeamAliasSync = Date()
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Stadium Alias sync
|
||||
entityStartTime = Date()
|
||||
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
||||
context: context,
|
||||
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiumAliases = stadiumAliases
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
syncState.lastStadiumAliasSync = Date()
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Game sync
|
||||
entityStartTime = Date()
|
||||
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
||||
let (games, skipIncompat4, skipOlder4) = try await syncGames(
|
||||
context: context,
|
||||
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalGames = games
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
syncState.lastGameSync = Date()
|
||||
totalSkippedIncompatible += skipIncompat4
|
||||
totalSkippedOlder += skipOlder4
|
||||
syncState.lastGameSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Sport sync
|
||||
// League Structure sync (non-critical for schedule rendering)
|
||||
entityStartTime = Date()
|
||||
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
||||
context: context,
|
||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalSports = sports
|
||||
totalSkippedIncompatible += skipIncompat7
|
||||
totalSkippedOlder += skipOlder7
|
||||
syncState.lastSportSync = Date()
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
do {
|
||||
let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
|
||||
context: context,
|
||||
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalLeagueStructures = leagueStructures
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
syncState.lastLeagueStructureSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Team Alias sync (non-critical for schedule rendering)
|
||||
entityStartTime = Date()
|
||||
do {
|
||||
let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases(
|
||||
context: context,
|
||||
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalTeamAliases = teamAliases
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
syncState.lastTeamAliasSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Stadium Alias sync (non-critical for schedule rendering)
|
||||
entityStartTime = Date()
|
||||
do {
|
||||
let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases(
|
||||
context: context,
|
||||
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiumAliases = stadiumAliases
|
||||
totalSkippedIncompatible += skipIncompat7
|
||||
totalSkippedOlder += skipOlder7
|
||||
syncState.lastStadiumAliasSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Mark sync successful - clear per-entity timestamps since full sync completed
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSuccessfulSync = Date()
|
||||
syncState.lastSyncError = nil
|
||||
syncState.lastSuccessfulSync = startTime
|
||||
syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
|
||||
syncState.consecutiveFailures = 0
|
||||
syncState.syncPausedReason = nil
|
||||
// Clear per-entity timestamps - they're only needed for partial recovery
|
||||
syncState.lastStadiumSync = nil
|
||||
syncState.lastTeamSync = nil
|
||||
@@ -305,12 +346,24 @@ actor CanonicalSyncService {
|
||||
// Mark sync failed
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSyncError = error.localizedDescription
|
||||
syncState.consecutiveFailures += 1
|
||||
|
||||
// Pause sync after too many failures
|
||||
if syncState.consecutiveFailures >= 5 {
|
||||
syncState.syncEnabled = false
|
||||
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
||||
if isTransientCloudKitError(error) {
|
||||
// Network/account hiccups should not permanently degrade sync health.
|
||||
syncState.consecutiveFailures = 0
|
||||
syncState.syncPausedReason = nil
|
||||
} else {
|
||||
syncState.consecutiveFailures += 1
|
||||
|
||||
// Pause sync after too many failures
|
||||
if syncState.consecutiveFailures >= 5 {
|
||||
#if DEBUG
|
||||
syncState.syncEnabled = false
|
||||
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
||||
#else
|
||||
syncState.consecutiveFailures = 5
|
||||
syncState.syncPausedReason = nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
@@ -347,6 +400,22 @@ actor CanonicalSyncService {
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool {
|
||||
guard let ckError = error as? CKError else { return false }
|
||||
switch ckError.code {
|
||||
case .networkUnavailable,
|
||||
.networkFailure,
|
||||
.serviceUnavailable,
|
||||
.requestRateLimited,
|
||||
.zoneBusy,
|
||||
.notAuthenticated,
|
||||
.accountTemporarilyUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual Sync Methods
|
||||
|
||||
@MainActor
|
||||
@@ -388,6 +457,12 @@ actor CanonicalSyncService {
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Single call for all teams with delta sync
|
||||
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
let activeStadiums = try context.fetch(
|
||||
FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -399,6 +474,7 @@ actor CanonicalSyncService {
|
||||
syncTeam.team,
|
||||
canonicalId: syncTeam.canonicalId,
|
||||
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -409,6 +485,10 @@ actor CanonicalSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if skippedIncompatible > 0 {
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@@ -420,6 +500,19 @@ actor CanonicalSyncService {
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Delta sync: nil = all games, Date = only modified since
|
||||
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
let activeTeams = try context.fetch(
|
||||
FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
let validTeamIds = Set(activeTeams.map(\.canonicalId))
|
||||
let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
|
||||
let activeStadiums = try context.fetch(
|
||||
FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -433,6 +526,9 @@ actor CanonicalSyncService {
|
||||
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
||||
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
||||
validTeamIds: validTeamIds,
|
||||
teamStadiumByTeamId: teamStadiumByTeamId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -443,6 +539,10 @@ actor CanonicalSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if skippedIncompatible > 0 {
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@@ -585,6 +685,9 @@ actor CanonicalSyncService {
|
||||
existing.timezoneIdentifier = remote.timeZoneIdentifier
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.replacedByCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
@@ -620,7 +723,8 @@ actor CanonicalSyncService {
|
||||
private func mergeTeam(
|
||||
_ remote: Team,
|
||||
canonicalId: String,
|
||||
stadiumCanonicalId: String,
|
||||
stadiumCanonicalId: String?,
|
||||
validStadiumIds: inout Set<String>,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
@@ -628,7 +732,32 @@ actor CanonicalSyncService {
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
|
||||
let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let resolvedStadiumCanonicalId: String
|
||||
if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) {
|
||||
resolvedStadiumCanonicalId = remoteStadiumId
|
||||
} else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) {
|
||||
resolvedStadiumCanonicalId = existingStadiumId
|
||||
} else if let remoteStadiumId, !remoteStadiumId.isEmpty {
|
||||
// Keep unresolved remote refs so teams/games can still sync while stadiums catch up.
|
||||
resolvedStadiumCanonicalId = remoteStadiumId
|
||||
} else if let existingStadiumId, !existingStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = existingStadiumId
|
||||
} else {
|
||||
// Last-resort placeholder keeps team records usable for game rendering.
|
||||
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
|
||||
}
|
||||
|
||||
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
|
||||
try ensurePlaceholderStadium(
|
||||
canonicalId: resolvedStadiumCanonicalId,
|
||||
sport: remote.sport,
|
||||
context: context
|
||||
)
|
||||
validStadiumIds.insert(resolvedStadiumCanonicalId)
|
||||
}
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
@@ -640,7 +769,7 @@ actor CanonicalSyncService {
|
||||
existing.abbreviation = remote.abbreviation
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.city = remote.city
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||
existing.logoURL = remote.logoURL?.absoluteString
|
||||
existing.primaryColor = remote.primaryColor
|
||||
existing.secondaryColor = remote.secondaryColor
|
||||
@@ -648,6 +777,9 @@ actor CanonicalSyncService {
|
||||
existing.divisionId = remote.divisionId
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.relocatedToCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
@@ -666,7 +798,7 @@ actor CanonicalSyncService {
|
||||
abbreviation: remote.abbreviation,
|
||||
sport: remote.sport.rawValue,
|
||||
city: remote.city,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||
logoURL: remote.logoURL?.absoluteString,
|
||||
primaryColor: remote.primaryColor,
|
||||
secondaryColor: remote.secondaryColor,
|
||||
@@ -684,7 +816,10 @@ actor CanonicalSyncService {
|
||||
canonicalId: String,
|
||||
homeTeamCanonicalId: String,
|
||||
awayTeamCanonicalId: String,
|
||||
stadiumCanonicalId: String,
|
||||
stadiumCanonicalId: String?,
|
||||
validTeamIds: Set<String>,
|
||||
teamStadiumByTeamId: [String: String],
|
||||
validStadiumIds: inout Set<String>,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
@@ -692,7 +827,56 @@ actor CanonicalSyncService {
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
|
||||
func resolveTeamId(remote: String, existing: String?) -> String? {
|
||||
if validTeamIds.contains(remote) {
|
||||
return remote
|
||||
}
|
||||
if let existing, validTeamIds.contains(existing) {
|
||||
return existing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId),
|
||||
let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId)
|
||||
else {
|
||||
return .skippedIncompatible
|
||||
}
|
||||
|
||||
let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackStadiumFromTeams = (
|
||||
teamStadiumByTeamId[resolvedHomeTeamId] ??
|
||||
teamStadiumByTeamId[resolvedAwayTeamId]
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let resolvedStadiumCanonicalId: String
|
||||
if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) {
|
||||
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
|
||||
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) {
|
||||
// Cloud record can have stale/legacy stadium refs; prefer known team home venue.
|
||||
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
|
||||
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) {
|
||||
// Keep existing local stadium if remote reference is invalid.
|
||||
resolvedStadiumCanonicalId = trimmedExistingStadiumId
|
||||
} else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
|
||||
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty {
|
||||
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
|
||||
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = trimmedExistingStadiumId
|
||||
} else {
|
||||
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
|
||||
}
|
||||
|
||||
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
|
||||
try ensurePlaceholderStadium(
|
||||
canonicalId: resolvedStadiumCanonicalId,
|
||||
sport: remote.sport,
|
||||
context: context
|
||||
)
|
||||
validStadiumIds.insert(resolvedStadiumCanonicalId)
|
||||
}
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
@@ -700,9 +884,9 @@ actor CanonicalSyncService {
|
||||
let savedNotes = existing.userNotes
|
||||
|
||||
// Update system fields
|
||||
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
||||
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.homeTeamCanonicalId = resolvedHomeTeamId
|
||||
existing.awayTeamCanonicalId = resolvedAwayTeamId
|
||||
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||
existing.dateTime = remote.dateTime
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.season = remote.season
|
||||
@@ -710,6 +894,9 @@ actor CanonicalSyncService {
|
||||
existing.broadcastInfo = remote.broadcastInfo
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.rescheduledToCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userAttending = savedAttending
|
||||
@@ -724,9 +911,9 @@ actor CanonicalSyncService {
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
homeTeamCanonicalId: resolvedHomeTeamId,
|
||||
awayTeamCanonicalId: resolvedAwayTeamId,
|
||||
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||
dateTime: remote.dateTime,
|
||||
sport: remote.sport.rawValue,
|
||||
season: remote.season,
|
||||
@@ -895,4 +1082,44 @@ actor CanonicalSyncService {
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func ensurePlaceholderStadium(
|
||||
canonicalId: String,
|
||||
sport: Sport,
|
||||
context: ModelContext
|
||||
) throws {
|
||||
let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedCanonicalId.isEmpty else { return }
|
||||
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.canonicalId == trimmedCanonicalId }
|
||||
)
|
||||
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
if existing.deprecatedAt != nil {
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.replacedByCanonicalId = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let placeholder = CanonicalStadium(
|
||||
canonicalId: trimmedCanonicalId,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
name: "Venue TBD",
|
||||
city: "Unknown",
|
||||
state: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
capacity: 0,
|
||||
sport: sport.rawValue,
|
||||
timezoneIdentifier: nil
|
||||
)
|
||||
context.insert(placeholder)
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,14 +62,55 @@ enum CloudKitError: Error, LocalizedError {
|
||||
actor CloudKitService {
|
||||
static let shared = CloudKitService()
|
||||
|
||||
nonisolated static let gameUpdatesSubscriptionID = "game-updates"
|
||||
nonisolated static let teamUpdatesSubscriptionID = "team-updates"
|
||||
nonisolated static let stadiumUpdatesSubscriptionID = "stadium-updates"
|
||||
nonisolated static let leagueStructureUpdatesSubscriptionID = "league-structure-updates"
|
||||
nonisolated static let teamAliasUpdatesSubscriptionID = "team-alias-updates"
|
||||
nonisolated static let stadiumAliasUpdatesSubscriptionID = "stadium-alias-updates"
|
||||
nonisolated static let sportUpdatesSubscriptionID = "sport-updates"
|
||||
nonisolated static let canonicalSubscriptionIDs: Set<String> = [
|
||||
gameUpdatesSubscriptionID,
|
||||
teamUpdatesSubscriptionID,
|
||||
stadiumUpdatesSubscriptionID,
|
||||
leagueStructureUpdatesSubscriptionID,
|
||||
teamAliasUpdatesSubscriptionID,
|
||||
stadiumAliasUpdatesSubscriptionID,
|
||||
sportUpdatesSubscriptionID
|
||||
]
|
||||
|
||||
nonisolated static func recordType(forSubscriptionID subscriptionID: String) -> String? {
|
||||
switch subscriptionID {
|
||||
case gameUpdatesSubscriptionID:
|
||||
return "Game"
|
||||
case teamUpdatesSubscriptionID:
|
||||
return "Team"
|
||||
case stadiumUpdatesSubscriptionID:
|
||||
return "Stadium"
|
||||
case leagueStructureUpdatesSubscriptionID:
|
||||
return "LeagueStructure"
|
||||
case teamAliasUpdatesSubscriptionID:
|
||||
return "TeamAlias"
|
||||
case stadiumAliasUpdatesSubscriptionID:
|
||||
return "StadiumAlias"
|
||||
case sportUpdatesSubscriptionID:
|
||||
return "Sport"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
/// Maximum records per CloudKit query (400 is the default limit)
|
||||
private let recordsPerPage = 400
|
||||
/// Re-fetch a small overlap window to avoid missing updates around sync boundaries.
|
||||
private let deltaOverlapSeconds: TimeInterval = 120
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
|
||||
// Use target entitlements (debug/prod) instead of hardcoding a container ID.
|
||||
self.container = CKContainer.default()
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
@@ -83,6 +124,8 @@ actor CloudKitService {
|
||||
) async throws -> [CKRecord] {
|
||||
var allRecords: [CKRecord] = []
|
||||
var cursor: CKQueryOperation.Cursor?
|
||||
var partialFailureCount = 0
|
||||
var firstPartialFailure: Error?
|
||||
|
||||
// First page
|
||||
let (firstResults, firstCursor) = try await publicDatabase.records(
|
||||
@@ -91,8 +134,14 @@ actor CloudKitService {
|
||||
)
|
||||
|
||||
for result in firstResults {
|
||||
if case .success(let record) = result.1 {
|
||||
switch result.1 {
|
||||
case .success(let record):
|
||||
allRecords.append(record)
|
||||
case .failure(let error):
|
||||
partialFailureCount += 1
|
||||
if firstPartialFailure == nil {
|
||||
firstPartialFailure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = firstCursor
|
||||
@@ -110,16 +159,51 @@ actor CloudKitService {
|
||||
)
|
||||
|
||||
for result in results {
|
||||
if case .success(let record) = result.1 {
|
||||
switch result.1 {
|
||||
case .success(let record):
|
||||
allRecords.append(record)
|
||||
case .failure(let error):
|
||||
partialFailureCount += 1
|
||||
if firstPartialFailure == nil {
|
||||
firstPartialFailure = error
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if partialFailureCount > 0 {
|
||||
SyncLogger.shared.log("⚠️ [CK] \(query.recordType) query had \(partialFailureCount) per-record failures")
|
||||
if allRecords.isEmpty, let firstPartialFailure {
|
||||
throw firstPartialFailure
|
||||
}
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
/// Normalizes a stored sync timestamp into a safe CloudKit delta start.
|
||||
/// - Returns: `nil` when a full sync should be used.
|
||||
private func effectiveDeltaStartDate(_ lastSync: Date?) -> Date? {
|
||||
guard let lastSync else { return nil }
|
||||
|
||||
let now = Date()
|
||||
if lastSync > now.addingTimeInterval(60) {
|
||||
SyncLogger.shared.log("⚠️ [CK] Last sync timestamp is in the future; falling back to full sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastSync.addingTimeInterval(-deltaOverlapSeconds)
|
||||
}
|
||||
|
||||
/// Fails fast when CloudKit returned records but none were parseable.
|
||||
private func throwIfAllDropped(recordType: String, totalRecords: Int, parsedRecords: Int) throws {
|
||||
guard totalRecords > 0, parsedRecords == 0 else { return }
|
||||
let message = "All \(recordType) records were unparseable (\(totalRecords) fetched)"
|
||||
SyncLogger.shared.log("❌ [CK] \(message)")
|
||||
throw CloudKitError.serverError(message)
|
||||
}
|
||||
|
||||
// MARK: - Sync Types (include canonical IDs from CloudKit)
|
||||
|
||||
struct SyncStadium {
|
||||
@@ -130,7 +214,7 @@ actor CloudKitService {
|
||||
struct SyncTeam {
|
||||
let team: Team
|
||||
let canonicalId: String
|
||||
let stadiumCanonicalId: String
|
||||
let stadiumCanonicalId: String?
|
||||
}
|
||||
|
||||
struct SyncGame {
|
||||
@@ -138,23 +222,29 @@ actor CloudKitService {
|
||||
let canonicalId: String
|
||||
let homeTeamCanonicalId: String
|
||||
let awayTeamCanonicalId: String
|
||||
let stadiumCanonicalId: String
|
||||
let stadiumCanonicalId: String?
|
||||
}
|
||||
|
||||
// MARK: - Availability Check
|
||||
|
||||
func isAvailable() async -> Bool {
|
||||
let status = await checkAccountStatus()
|
||||
return status == .available
|
||||
switch status {
|
||||
case .available, .noAccount, .couldNotDetermine:
|
||||
// Public DB reads should still be attempted without an iCloud account.
|
||||
return true
|
||||
case .restricted, .temporarilyUnavailable:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func checkAvailabilityWithError() async throws {
|
||||
let status = await checkAccountStatus()
|
||||
switch status {
|
||||
case .available:
|
||||
case .available, .noAccount:
|
||||
return
|
||||
case .noAccount:
|
||||
throw CloudKitError.notSignedIn
|
||||
case .restricted:
|
||||
throw CloudKitError.permissionDenied
|
||||
case .couldNotDetermine:
|
||||
@@ -268,23 +358,39 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching stadiums modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL stadiums (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) stadium records from CloudKit")
|
||||
|
||||
return records.compactMap { record -> SyncStadium? in
|
||||
var validStadiums: [SyncStadium] = []
|
||||
var skipped = 0
|
||||
for record in records {
|
||||
let ckStadium = CKStadium(record: record)
|
||||
guard let stadium = ckStadium.stadium,
|
||||
let canonicalId = ckStadium.canonicalId
|
||||
else { return nil }
|
||||
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
|
||||
else {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
validStadiums.append(SyncStadium(stadium: stadium, canonicalId: canonicalId))
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) stadium records due to missing required fields")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.stadium, totalRecords: records.count, parsedRecords: validStadiums.count)
|
||||
|
||||
return validStadiums
|
||||
}
|
||||
|
||||
/// Fetch teams for sync operations
|
||||
@@ -292,24 +398,47 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching teams modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL teams (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) team records from CloudKit")
|
||||
|
||||
return records.compactMap { record -> SyncTeam? in
|
||||
var validTeams: [SyncTeam] = []
|
||||
var skipped = 0
|
||||
var missingStadiumRef = 0
|
||||
for record in records {
|
||||
let ckTeam = CKTeam(record: record)
|
||||
guard let team = ckTeam.team,
|
||||
let canonicalId = ckTeam.canonicalId,
|
||||
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
|
||||
else { return nil }
|
||||
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
|
||||
let canonicalId = ckTeam.canonicalId
|
||||
else {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
|
||||
if stadiumCanonicalId == nil {
|
||||
missingStadiumRef += 1
|
||||
}
|
||||
validTeams.append(SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId))
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) team records due to missing required fields")
|
||||
}
|
||||
if missingStadiumRef > 0 {
|
||||
log.log("⚠️ [CK] \(missingStadiumRef) team records are missing stadium refs; merge will preserve local stadiums when possible")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.team, totalRecords: records.count, parsedRecords: validTeams.count)
|
||||
|
||||
return validTeams
|
||||
}
|
||||
|
||||
/// Fetch games for sync operations
|
||||
@@ -319,9 +448,9 @@ actor CloudKitService {
|
||||
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching games modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL games (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
@@ -334,23 +463,28 @@ actor CloudKitService {
|
||||
var validGames: [SyncGame] = []
|
||||
var skippedMissingIds = 0
|
||||
var skippedInvalidGame = 0
|
||||
var missingStadiumRef = 0
|
||||
|
||||
for record in records {
|
||||
let ckGame = CKGame(record: record)
|
||||
|
||||
guard let canonicalId = ckGame.canonicalId,
|
||||
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
||||
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
||||
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
||||
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId
|
||||
else {
|
||||
skippedMissingIds += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
||||
if stadiumCanonicalId == nil {
|
||||
missingStadiumRef += 1
|
||||
}
|
||||
|
||||
guard let game = ckGame.game(
|
||||
homeTeamId: homeTeamCanonicalId,
|
||||
awayTeamId: awayTeamCanonicalId,
|
||||
stadiumId: stadiumCanonicalId
|
||||
stadiumId: stadiumCanonicalId ?? "stadium_placeholder_\(canonicalId)"
|
||||
) else {
|
||||
skippedInvalidGame += 1
|
||||
continue
|
||||
@@ -366,6 +500,10 @@ actor CloudKitService {
|
||||
}
|
||||
|
||||
log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)")
|
||||
if missingStadiumRef > 0 {
|
||||
log.log("⚠️ [CK] \(missingStadiumRef) games are missing stadium refs; merge will derive stadiums from team mappings when possible")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.game, totalRecords: records.count, parsedRecords: validGames.count)
|
||||
|
||||
// Log sport breakdown
|
||||
var bySport: [String: Int] = [:]
|
||||
@@ -443,20 +581,37 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching league structures modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL league structures (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
|
||||
|
||||
return records.compactMap { record in
|
||||
return CKLeagueStructure(record: record).toModel()
|
||||
var parsed: [LeagueStructureModel] = []
|
||||
var skipped = 0
|
||||
for record in records {
|
||||
if let model = CKLeagueStructure(record: record).toModel() {
|
||||
parsed.append(model)
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) league structure records due to missing required fields")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.leagueStructure, totalRecords: records.count, parsedRecords: parsed.count)
|
||||
|
||||
return parsed.sorted { lhs, rhs in
|
||||
lhs.lastModified < rhs.lastModified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,20 +620,37 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching team aliases modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL team aliases (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
|
||||
|
||||
return records.compactMap { record in
|
||||
return CKTeamAlias(record: record).toModel()
|
||||
var parsed: [TeamAlias] = []
|
||||
var skipped = 0
|
||||
for record in records {
|
||||
if let model = CKTeamAlias(record: record).toModel() {
|
||||
parsed.append(model)
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) team alias records due to missing required fields")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.teamAlias, totalRecords: records.count, parsedRecords: parsed.count)
|
||||
|
||||
return parsed.sorted { lhs, rhs in
|
||||
lhs.lastModified < rhs.lastModified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,20 +659,37 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching stadium aliases modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL stadium aliases (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
|
||||
|
||||
return records.compactMap { record in
|
||||
return CKStadiumAlias(record: record).toModel()
|
||||
var parsed: [StadiumAlias] = []
|
||||
var skipped = 0
|
||||
for record in records {
|
||||
if let model = CKStadiumAlias(record: record).toModel() {
|
||||
parsed.append(model)
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) stadium alias records due to missing required fields")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.stadiumAlias, totalRecords: records.count, parsedRecords: parsed.count)
|
||||
|
||||
return parsed.sorted { lhs, rhs in
|
||||
lhs.lastModified < rhs.lastModified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,19 +700,36 @@ actor CloudKitService {
|
||||
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
|
||||
let log = SyncLogger.shared
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||
log.log("☁️ [CK] Fetching sports modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||
} else {
|
||||
log.log("☁️ [CK] Fetching ALL sports (full sync)")
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
||||
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
|
||||
|
||||
return records.compactMap { record -> CanonicalSport? in
|
||||
return CKSport(record: record).toCanonical()
|
||||
var parsed: [CanonicalSport] = []
|
||||
var skipped = 0
|
||||
for record in records {
|
||||
if let sport = CKSport(record: record).toCanonical() {
|
||||
parsed.append(sport)
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
log.log("⚠️ [CK] Skipped \(skipped) sport records due to missing required fields")
|
||||
}
|
||||
try throwIfAllDropped(recordType: CKRecordType.sport, totalRecords: records.count, parsedRecords: parsed.count)
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
// MARK: - Sync Status
|
||||
@@ -542,83 +748,130 @@ actor CloudKitService {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.game,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "game-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
subscriptionID: Self.gameUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToTeamUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.team,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: Self.teamUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToStadiumUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.stadium,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: Self.stadiumUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToLeagueStructureUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.leagueStructure,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "league-structure-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
subscriptionID: Self.leagueStructureUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToTeamAliasUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.teamAlias,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "team-alias-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
subscriptionID: Self.teamAliasUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToStadiumAliasUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.stadiumAlias,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "stadium-alias-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
subscriptionID: Self.stadiumAliasUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
func subscribeToSportUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.sport,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "sport-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
subscriptionID: Self.sportUpdatesSubscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
try await saveSubscriptionIfNeeded(subscription)
|
||||
}
|
||||
|
||||
/// Subscribe to all canonical data updates
|
||||
func subscribeToAllUpdates() async throws {
|
||||
try await subscribeToScheduleUpdates()
|
||||
try await subscribeToTeamUpdates()
|
||||
try await subscribeToStadiumUpdates()
|
||||
try await subscribeToLeagueStructureUpdates()
|
||||
try await subscribeToTeamAliasUpdates()
|
||||
try await subscribeToStadiumAliasUpdates()
|
||||
try await subscribeToSportUpdates()
|
||||
}
|
||||
|
||||
private func saveSubscriptionIfNeeded(_ subscription: CKQuerySubscription) async throws {
|
||||
do {
|
||||
try await publicDatabase.save(subscription)
|
||||
} catch let error as CKError where error.code == .serverRejectedRequest {
|
||||
// Existing subscriptions can be rejected as duplicates in some environments.
|
||||
// Confirm it exists before treating this as non-fatal.
|
||||
do {
|
||||
_ = try await publicDatabase.subscription(for: subscription.subscriptionID)
|
||||
SyncLogger.shared.log("ℹ️ [CK] Subscription already exists: \(subscription.subscriptionID)")
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,22 +221,26 @@ final class AppDataProvider: ObservableObject {
|
||||
|
||||
var richGames: [RichGame] = []
|
||||
var droppedGames: [(game: Game, reason: String)] = []
|
||||
var stadiumFallbacksApplied = 0
|
||||
|
||||
for game in games {
|
||||
let homeTeam = teamsById[game.homeTeamId]
|
||||
let awayTeam = teamsById[game.awayTeamId]
|
||||
let stadium = stadiumsById[game.stadiumId]
|
||||
let resolvedStadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam)
|
||||
if resolvedStadium?.id != game.stadiumId {
|
||||
stadiumFallbacksApplied += 1
|
||||
}
|
||||
|
||||
if homeTeam == nil || awayTeam == nil || stadium == nil {
|
||||
if homeTeam == nil || awayTeam == nil || resolvedStadium == nil {
|
||||
var reasons: [String] = []
|
||||
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
|
||||
if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") }
|
||||
if stadium == nil { reasons.append("stadium(\(game.stadiumId))") }
|
||||
if resolvedStadium == nil { reasons.append("stadium(\(game.stadiumId))") }
|
||||
droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))"))
|
||||
continue
|
||||
}
|
||||
|
||||
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: stadium!))
|
||||
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!))
|
||||
}
|
||||
|
||||
if !droppedGames.isEmpty {
|
||||
@@ -248,6 +252,9 @@ final class AppDataProvider: ObservableObject {
|
||||
print("⚠️ [DATA] ... and \(droppedGames.count - 10) more")
|
||||
}
|
||||
}
|
||||
if stadiumFallbacksApplied > 0 {
|
||||
print("⚠️ [DATA] Applied stadium fallback for \(stadiumFallbacksApplied) games")
|
||||
}
|
||||
|
||||
print("🎮 [DATA] Returning \(richGames.count) rich games")
|
||||
return richGames
|
||||
@@ -260,7 +267,7 @@ final class AppDataProvider: ObservableObject {
|
||||
return games.compactMap { game in
|
||||
guard let homeTeam = teamsById[game.homeTeamId],
|
||||
let awayTeam = teamsById[game.awayTeamId],
|
||||
let stadium = stadiumsById[game.stadiumId] else {
|
||||
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||
return nil
|
||||
}
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
@@ -270,7 +277,7 @@ final class AppDataProvider: ObservableObject {
|
||||
func richGame(from game: Game) -> RichGame? {
|
||||
guard let homeTeam = teamsById[game.homeTeamId],
|
||||
let awayTeam = teamsById[game.awayTeamId],
|
||||
let stadium = stadiumsById[game.stadiumId] else {
|
||||
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||
return nil
|
||||
}
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
@@ -299,13 +306,27 @@ final class AppDataProvider: ObservableObject {
|
||||
let game = canonical.toDomain()
|
||||
guard let homeTeam = teamsById[game.homeTeamId],
|
||||
let awayTeam = teamsById[game.awayTeamId],
|
||||
let stadium = stadiumsById[game.stadiumId] else {
|
||||
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||
continue
|
||||
}
|
||||
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
|
||||
}
|
||||
return teamGames
|
||||
}
|
||||
|
||||
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.
|
||||
private func resolveStadium(for game: Game, homeTeam: Team?, awayTeam: Team?) -> Stadium? {
|
||||
if let stadium = stadiumsById[game.stadiumId] {
|
||||
return stadium
|
||||
}
|
||||
if let homeTeam, let fallback = stadiumsById[homeTeam.stadiumId] {
|
||||
return fallback
|
||||
}
|
||||
if let awayTeam, let fallback = stadiumsById[awayTeam.stadiumId] {
|
||||
return fallback
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
@@ -5,7 +5,7 @@ import CloudKit
|
||||
actor ItineraryItemService {
|
||||
static let shared = ItineraryItemService()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
|
||||
private let container = CKContainer.default()
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
|
||||
private let recordType = "ItineraryItem"
|
||||
|
||||
@@ -52,7 +52,8 @@ actor PollService {
|
||||
private var pollSubscriptionID: CKSubscription.ID?
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
|
||||
// Respect target entitlements so Debug and production stay isolated.
|
||||
self.container = CKContainer.default()
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ import os
|
||||
|
||||
/// Protocol for cancellation tokens checked between sync pages
|
||||
protocol SyncCancellationToken: Sendable {
|
||||
var isCancelled: Bool { get }
|
||||
nonisolated var isCancelled: Bool { get }
|
||||
}
|
||||
|
||||
/// Concrete cancellation token for background tasks
|
||||
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
var isCancelled: Bool {
|
||||
nonisolated var isCancelled: Bool {
|
||||
lock.withLock { $0 }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SyncLogger {
|
||||
nonisolated final class SyncLogger {
|
||||
static let shared = SyncLogger()
|
||||
|
||||
private let fileURL: URL
|
||||
|
||||
@@ -63,7 +63,7 @@ final class VisitPhotoService {
|
||||
|
||||
init(modelContext: ModelContext) {
|
||||
self.modelContext = modelContext
|
||||
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
|
||||
self.container = CKContainer.default()
|
||||
self.privateDatabase = container.privateCloudDatabase
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ struct SettingsView: View {
|
||||
@State private var showResetConfirmation = false
|
||||
@State private var showPaywall = false
|
||||
@State private var showOnboardingPaywall = false
|
||||
@State private var showSyncLogs = false
|
||||
@State private var isSyncActionInProgress = false
|
||||
@State private var syncActionMessage: String?
|
||||
#if DEBUG
|
||||
@State private var selectedSyncStatus: EntitySyncStatus?
|
||||
@State private var showSyncLogs = false
|
||||
@State private var exporter = DebugShareExporter()
|
||||
@State private var showExportProgress = false
|
||||
#endif
|
||||
@@ -39,6 +41,9 @@ struct SettingsView: View {
|
||||
// Travel Preferences
|
||||
travelSection
|
||||
|
||||
// Data Sync
|
||||
syncHealthSection
|
||||
|
||||
// Privacy
|
||||
privacySection
|
||||
|
||||
@@ -72,6 +77,17 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $showOnboardingPaywall) {
|
||||
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
|
||||
}
|
||||
.sheet(isPresented: $showSyncLogs) {
|
||||
SyncLogViewerSheet()
|
||||
}
|
||||
.alert("Sync Status", isPresented: Binding(
|
||||
get: { syncActionMessage != nil },
|
||||
set: { if !$0 { syncActionMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { syncActionMessage = nil }
|
||||
} message: {
|
||||
Text(syncActionMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Section
|
||||
@@ -370,6 +386,97 @@ struct SettingsView: View {
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Sync Health Section
|
||||
|
||||
private var syncHealthSection: some View {
|
||||
Section {
|
||||
let syncState = SyncState.current(in: modelContext)
|
||||
|
||||
if syncState.syncInProgress || isSyncActionInProgress {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Sync in progress...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let lastSync = syncState.lastSuccessfulSync {
|
||||
HStack {
|
||||
Label("Last Successful Sync", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Spacer()
|
||||
Text(lastSync.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Label("Last Successful Sync", systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("Never")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let lastError = syncState.lastSyncError, !lastError.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Last Sync Warning", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.subheadline)
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !syncState.syncEnabled {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Sync Paused", systemImage: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.subheadline)
|
||||
if let reason = syncState.syncPausedReason {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
let syncService = CanonicalSyncService()
|
||||
await syncService.resumeSync(context: modelContext)
|
||||
syncActionMessage = "Sync has been re-enabled."
|
||||
}
|
||||
} label: {
|
||||
Label("Re-enable Sync", systemImage: "play.circle")
|
||||
}
|
||||
.disabled(isSyncActionInProgress)
|
||||
}
|
||||
|
||||
Button {
|
||||
triggerManualSync()
|
||||
} label: {
|
||||
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.disabled(isSyncActionInProgress)
|
||||
|
||||
Button {
|
||||
showSyncLogs = true
|
||||
} label: {
|
||||
Label("View Sync Logs", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
} header: {
|
||||
Text("Data Sync")
|
||||
} footer: {
|
||||
Text("SportsTime loads bundled data first, then refreshes from CloudKit.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Debug Section
|
||||
|
||||
#if DEBUG
|
||||
@@ -548,9 +655,6 @@ struct SettingsView: View {
|
||||
.sheet(item: $selectedSyncStatus) { status in
|
||||
SyncStatusDetailSheet(status: status)
|
||||
}
|
||||
.sheet(isPresented: $showSyncLogs) {
|
||||
SyncLogViewerSheet()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -633,6 +737,23 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func triggerManualSync() {
|
||||
guard !isSyncActionInProgress else { return }
|
||||
|
||||
isSyncActionInProgress = true
|
||||
|
||||
Task {
|
||||
defer { isSyncActionInProgress = false }
|
||||
|
||||
do {
|
||||
let result = try await BackgroundSyncManager.shared.triggerManualSync()
|
||||
syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records."
|
||||
} catch {
|
||||
syncActionMessage = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sportColor(for sport: Sport) -> Color {
|
||||
sport.themeColor
|
||||
}
|
||||
@@ -775,6 +896,8 @@ private struct DetailRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/// Sheet to view sync logs
|
||||
struct SyncLogViewerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -837,5 +960,3 @@ struct SyncLogViewerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<string>production</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.88oakapps.SportsTime</string>
|
||||
|
||||
@@ -18,6 +18,9 @@ struct SportsTimeApp: App {
|
||||
private var transactionListener: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
// Configure sync manager immediately so push/background triggers can sync.
|
||||
BackgroundSyncManager.shared.configure(with: sharedModelContainer)
|
||||
|
||||
// Register background tasks BEFORE app finishes launching
|
||||
// This must happen synchronously in init or applicationDidFinishLaunching
|
||||
BackgroundSyncManager.shared.registerTasks()
|
||||
@@ -181,6 +184,9 @@ struct BootstrappedContentView: View {
|
||||
// 4. Load data from SwiftData into memory
|
||||
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
|
||||
await AppDataProvider.shared.loadInitialData()
|
||||
if let loadError = AppDataProvider.shared.error {
|
||||
throw loadError
|
||||
}
|
||||
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams")
|
||||
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
|
||||
|
||||
@@ -207,10 +213,15 @@ struct BootstrappedContentView: View {
|
||||
// 9. Schedule background tasks for future syncs
|
||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||
|
||||
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
|
||||
Task(priority: .utility) {
|
||||
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
|
||||
}
|
||||
|
||||
// 10. Background: Try to refresh from CloudKit (non-blocking)
|
||||
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
||||
Task.detached(priority: .background) {
|
||||
await self.performBackgroundSync(context: context)
|
||||
Task(priority: .background) {
|
||||
await self.performBackgroundSync(context: self.modelContainer.mainContext)
|
||||
await MainActor.run {
|
||||
self.hasCompletedInitialSync = true
|
||||
}
|
||||
@@ -227,9 +238,16 @@ struct BootstrappedContentView: View {
|
||||
let log = SyncLogger.shared
|
||||
log.log("🔄 [SYNC] Starting background sync...")
|
||||
|
||||
// Reset stale syncInProgress flag (in case app was killed mid-sync)
|
||||
// Only reset stale syncInProgress flags; do not clobber an actively running sync.
|
||||
let syncState = SyncState.current(in: context)
|
||||
if syncState.syncInProgress {
|
||||
let staleSyncTimeout: TimeInterval = 15 * 60
|
||||
if let lastAttempt = syncState.lastSyncAttempt,
|
||||
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
|
||||
log.log("ℹ️ [SYNC] Sync already in progress; skipping duplicate trigger")
|
||||
return
|
||||
}
|
||||
|
||||
log.log("⚠️ [SYNC] Resetting stale syncInProgress flag")
|
||||
syncState.syncInProgress = false
|
||||
try? context.save()
|
||||
|
||||
Reference in New Issue
Block a user