chore: commit all pending changes
@@ -40,7 +40,13 @@
|
|||||||
"Bash(echo:*)",
|
"Bash(echo:*)",
|
||||||
"Bash(ffprobe:*)",
|
"Bash(ffprobe:*)",
|
||||||
"WebFetch(domain:www.cbssports.com)",
|
"WebFetch(domain:www.cbssports.com)",
|
||||||
"WebFetch(domain:www.mlb.com)"
|
"WebFetch(domain:www.mlb.com)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:swiftpackageindex.com)",
|
||||||
|
"WebFetch(domain:posthog.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(swift package add-dependency:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// 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 team = "Team"
|
||||||
static let stadium = "Stadium"
|
static let stadium = "Stadium"
|
||||||
static let game = "Game"
|
static let game = "Game"
|
||||||
@@ -25,7 +31,7 @@ enum CKRecordType {
|
|||||||
|
|
||||||
// MARK: - CKTeam
|
// MARK: - CKTeam
|
||||||
|
|
||||||
struct CKTeam {
|
nonisolated struct CKTeam {
|
||||||
static let idKey = "teamId"
|
static let idKey = "teamId"
|
||||||
static let canonicalIdKey = "canonicalId"
|
static let canonicalIdKey = "canonicalId"
|
||||||
static let nameKey = "name"
|
static let nameKey = "name"
|
||||||
@@ -62,12 +68,22 @@ struct CKTeam {
|
|||||||
|
|
||||||
/// The canonical ID string from CloudKit (e.g., "team_nba_atl")
|
/// The canonical ID string from CloudKit (e.g., "team_nba_atl")
|
||||||
var canonicalId: String? {
|
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")
|
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
|
||||||
var stadiumCanonicalId: String? {
|
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")
|
/// The conference canonical ID string from CloudKit (e.g., "nba_eastern")
|
||||||
@@ -82,16 +98,18 @@ struct CKTeam {
|
|||||||
|
|
||||||
var team: Team? {
|
var team: Team? {
|
||||||
// Use teamId field, or fall back to record name
|
// 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,
|
guard !id.isEmpty,
|
||||||
let abbreviation = record[CKTeam.abbreviationKey] as? String,
|
let abbreviation, !abbreviation.isEmpty,
|
||||||
let sportRaw = record[CKTeam.sportKey] as? String,
|
let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased()),
|
||||||
let sport = Sport(rawValue: sportRaw),
|
let city, !city.isEmpty
|
||||||
let city = record[CKTeam.cityKey] as? String
|
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
// Name defaults to abbreviation if not provided
|
// 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
|
// Stadium reference is optional - use placeholder string if not present
|
||||||
let stadiumId: String
|
let stadiumId: String
|
||||||
@@ -122,7 +140,7 @@ struct CKTeam {
|
|||||||
|
|
||||||
// MARK: - CKStadium
|
// MARK: - CKStadium
|
||||||
|
|
||||||
struct CKStadium {
|
nonisolated struct CKStadium {
|
||||||
static let idKey = "stadiumId"
|
static let idKey = "stadiumId"
|
||||||
static let canonicalIdKey = "canonicalId"
|
static let canonicalIdKey = "canonicalId"
|
||||||
static let nameKey = "name"
|
static let nameKey = "name"
|
||||||
@@ -157,25 +175,31 @@ struct CKStadium {
|
|||||||
|
|
||||||
/// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
|
/// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
|
||||||
var canonicalId: String? {
|
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? {
|
var stadium: Stadium? {
|
||||||
// Use stadiumId field, or fall back to record name
|
// 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,
|
guard !id.isEmpty,
|
||||||
let name = record[CKStadium.nameKey] as? String,
|
let name, !name.isEmpty,
|
||||||
let city = record[CKStadium.cityKey] as? String
|
let city, !city.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
// These fields are optional in CloudKit
|
// 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 location = record[CKStadium.locationKey] as? CLLocation
|
||||||
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
||||||
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
||||||
let sportRaw = record[CKStadium.sportKey] as? String ?? "MLB"
|
let sportRaw = ((record[CKStadium.sportKey] as? String)?.ckTrimmed).flatMap { $0.isEmpty ? nil : $0 } ?? "MLB"
|
||||||
let sport = Sport(rawValue: sportRaw) ?? .mlb
|
let sport = Sport(rawValue: sportRaw.uppercased()) ?? .mlb
|
||||||
let timezoneIdentifier = record[CKStadium.timezoneIdentifierKey] as? String
|
let timezoneIdentifier = (record[CKStadium.timezoneIdentifierKey] as? String)?.ckTrimmed
|
||||||
|
|
||||||
return Stadium(
|
return Stadium(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -195,7 +219,7 @@ struct CKStadium {
|
|||||||
|
|
||||||
// MARK: - CKGame
|
// MARK: - CKGame
|
||||||
|
|
||||||
struct CKGame {
|
nonisolated struct CKGame {
|
||||||
static let idKey = "gameId"
|
static let idKey = "gameId"
|
||||||
static let canonicalIdKey = "canonicalId"
|
static let canonicalIdKey = "canonicalId"
|
||||||
static let homeTeamRefKey = "homeTeamRef"
|
static let homeTeamRefKey = "homeTeamRef"
|
||||||
@@ -232,31 +256,64 @@ struct CKGame {
|
|||||||
|
|
||||||
/// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc")
|
/// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc")
|
||||||
var canonicalId: String? {
|
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")
|
/// The home team canonical ID string from CloudKit (e.g., "team_nba_okc")
|
||||||
var homeTeamCanonicalId: String? {
|
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")
|
/// The away team canonical ID string from CloudKit (e.g., "team_nba_hou")
|
||||||
var awayTeamCanonicalId: String? {
|
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")
|
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_paycom_center")
|
||||||
var stadiumCanonicalId: String? {
|
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? {
|
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,
|
guard !id.isEmpty,
|
||||||
let dateTime = record[CKGame.dateTimeKey] as? Date,
|
let dateTime = record[CKGame.dateTimeKey] as? Date,
|
||||||
let sportRaw = record[CKGame.sportKey] as? String,
|
let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased())
|
||||||
let sport = Sport(rawValue: sportRaw),
|
|
||||||
let season = record[CKGame.seasonKey] as? String
|
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
return Game(
|
return Game(
|
||||||
@@ -267,7 +324,13 @@ struct CKGame {
|
|||||||
dateTime: dateTime,
|
dateTime: dateTime,
|
||||||
sport: sport,
|
sport: sport,
|
||||||
season: season,
|
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
|
broadcastInfo: record[CKGame.broadcastInfoKey] as? String
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -275,7 +338,7 @@ struct CKGame {
|
|||||||
|
|
||||||
// MARK: - CKLeagueStructure
|
// MARK: - CKLeagueStructure
|
||||||
|
|
||||||
struct CKLeagueStructure {
|
nonisolated struct CKLeagueStructure {
|
||||||
static let idKey = "structureId"
|
static let idKey = "structureId"
|
||||||
static let sportKey = "sport"
|
static let sportKey = "sport"
|
||||||
static let typeKey = "type"
|
static let typeKey = "type"
|
||||||
@@ -308,15 +371,18 @@ struct CKLeagueStructure {
|
|||||||
|
|
||||||
/// Convert to SwiftData model for local storage
|
/// Convert to SwiftData model for local storage
|
||||||
func toModel() -> LeagueStructureModel? {
|
func toModel() -> LeagueStructureModel? {
|
||||||
guard let id = record[CKLeagueStructure.idKey] as? String,
|
let id = ((record[CKLeagueStructure.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||||
let sport = record[CKLeagueStructure.sportKey] as? String,
|
let sport = (record[CKLeagueStructure.sportKey] as? String)?.ckTrimmed.uppercased()
|
||||||
|
guard !id.isEmpty,
|
||||||
|
let sport, !sport.isEmpty,
|
||||||
let typeRaw = record[CKLeagueStructure.typeKey] as? String,
|
let typeRaw = record[CKLeagueStructure.typeKey] as? String,
|
||||||
let structureType = LeagueStructureType(rawValue: typeRaw),
|
let structureType = LeagueStructureType(rawValue: typeRaw.lowercased()),
|
||||||
let name = record[CKLeagueStructure.nameKey] as? String
|
let name = (record[CKLeagueStructure.nameKey] as? String)?.ckTrimmed,
|
||||||
|
!name.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String
|
let abbreviation = (record[CKLeagueStructure.abbreviationKey] as? String)?.ckTrimmed
|
||||||
let parentId = record[CKLeagueStructure.parentIdKey] as? String
|
let parentId = (record[CKLeagueStructure.parentIdKey] as? String)?.ckTrimmed
|
||||||
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
|
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
|
||||||
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
|
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||||
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||||
@@ -337,7 +403,7 @@ struct CKLeagueStructure {
|
|||||||
|
|
||||||
// MARK: - CKSport
|
// MARK: - CKSport
|
||||||
|
|
||||||
struct CKSport {
|
nonisolated struct CKSport {
|
||||||
static let idKey = "sportId"
|
static let idKey = "sportId"
|
||||||
static let abbreviationKey = "abbreviation"
|
static let abbreviationKey = "abbreviation"
|
||||||
static let displayNameKey = "displayName"
|
static let displayNameKey = "displayName"
|
||||||
@@ -357,16 +423,49 @@ struct CKSport {
|
|||||||
|
|
||||||
/// Convert to CanonicalSport for local storage
|
/// Convert to CanonicalSport for local storage
|
||||||
func toCanonical() -> CanonicalSport? {
|
func toCanonical() -> CanonicalSport? {
|
||||||
guard let id = record[CKSport.idKey] as? String,
|
let id = ((record[CKSport.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||||
let abbreviation = record[CKSport.abbreviationKey] as? String,
|
let seasonStartMonth: Int?
|
||||||
let displayName = record[CKSport.displayNameKey] as? String,
|
if let intValue = record[CKSport.seasonStartMonthKey] as? Int {
|
||||||
let iconName = record[CKSport.iconNameKey] as? String,
|
seasonStartMonth = intValue
|
||||||
let colorHex = record[CKSport.colorHexKey] as? String,
|
} else if let int64Value = record[CKSport.seasonStartMonthKey] as? Int64 {
|
||||||
let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int,
|
seasonStartMonth = Int(int64Value)
|
||||||
let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int
|
} 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 }
|
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 schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||||
let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||||
|
|
||||||
@@ -388,7 +487,7 @@ struct CKSport {
|
|||||||
|
|
||||||
// MARK: - CKStadiumAlias
|
// MARK: - CKStadiumAlias
|
||||||
|
|
||||||
struct CKStadiumAlias {
|
nonisolated struct CKStadiumAlias {
|
||||||
static let aliasNameKey = "aliasName"
|
static let aliasNameKey = "aliasName"
|
||||||
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
|
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
|
||||||
static let validFromKey = "validFrom"
|
static let validFromKey = "validFrom"
|
||||||
@@ -415,8 +514,10 @@ struct CKStadiumAlias {
|
|||||||
|
|
||||||
/// Convert to SwiftData model for local storage
|
/// Convert to SwiftData model for local storage
|
||||||
func toModel() -> StadiumAlias? {
|
func toModel() -> StadiumAlias? {
|
||||||
guard let aliasName = record[CKStadiumAlias.aliasNameKey] as? String,
|
let aliasName = ((record[CKStadiumAlias.aliasNameKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||||
let stadiumCanonicalId = record[CKStadiumAlias.stadiumCanonicalIdKey] as? String
|
guard !aliasName.isEmpty,
|
||||||
|
let stadiumCanonicalId = (record[CKStadiumAlias.stadiumCanonicalIdKey] as? String)?.ckTrimmed,
|
||||||
|
!stadiumCanonicalId.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let validFrom = record[CKStadiumAlias.validFromKey] as? Date
|
let validFrom = record[CKStadiumAlias.validFromKey] as? Date
|
||||||
@@ -437,7 +538,7 @@ struct CKStadiumAlias {
|
|||||||
|
|
||||||
// MARK: - CKTeamAlias
|
// MARK: - CKTeamAlias
|
||||||
|
|
||||||
struct CKTeamAlias {
|
nonisolated struct CKTeamAlias {
|
||||||
static let idKey = "aliasId"
|
static let idKey = "aliasId"
|
||||||
static let teamCanonicalIdKey = "teamCanonicalId"
|
static let teamCanonicalIdKey = "teamCanonicalId"
|
||||||
static let aliasTypeKey = "aliasType"
|
static let aliasTypeKey = "aliasType"
|
||||||
@@ -468,11 +569,14 @@ struct CKTeamAlias {
|
|||||||
|
|
||||||
/// Convert to SwiftData model for local storage
|
/// Convert to SwiftData model for local storage
|
||||||
func toModel() -> TeamAlias? {
|
func toModel() -> TeamAlias? {
|
||||||
guard let id = record[CKTeamAlias.idKey] as? String,
|
let id = ((record[CKTeamAlias.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
|
||||||
let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String,
|
guard !id.isEmpty,
|
||||||
let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String,
|
let teamCanonicalId = (record[CKTeamAlias.teamCanonicalIdKey] as? String)?.ckTrimmed,
|
||||||
let aliasType = TeamAliasType(rawValue: aliasTypeRaw),
|
!teamCanonicalId.isEmpty,
|
||||||
let aliasValue = record[CKTeamAlias.aliasValueKey] as? String
|
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 }
|
else { return nil }
|
||||||
|
|
||||||
let validFrom = record[CKTeamAlias.validFromKey] as? Date
|
let validFrom = record[CKTeamAlias.validFromKey] as? Date
|
||||||
@@ -495,7 +599,7 @@ struct CKTeamAlias {
|
|||||||
|
|
||||||
// MARK: - CKTripPoll
|
// MARK: - CKTripPoll
|
||||||
|
|
||||||
struct CKTripPoll {
|
nonisolated struct CKTripPoll {
|
||||||
static let pollIdKey = "pollId"
|
static let pollIdKey = "pollId"
|
||||||
static let titleKey = "title"
|
static let titleKey = "title"
|
||||||
static let ownerIdKey = "ownerId"
|
static let ownerIdKey = "ownerId"
|
||||||
@@ -594,7 +698,7 @@ struct CKTripPoll {
|
|||||||
|
|
||||||
// MARK: - CKPollVote
|
// MARK: - CKPollVote
|
||||||
|
|
||||||
struct CKPollVote {
|
nonisolated struct CKPollVote {
|
||||||
static let voteIdKey = "voteId"
|
static let voteIdKey = "voteId"
|
||||||
static let pollIdKey = "pollId"
|
static let pollIdKey = "pollId"
|
||||||
static let voterIdKey = "voterId"
|
static let voterIdKey = "voterId"
|
||||||
@@ -640,4 +744,3 @@ struct CKPollVote {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import CryptoKit
|
|||||||
/// Schema version constants for canonical data models.
|
/// Schema version constants for canonical data models.
|
||||||
/// Marked nonisolated to allow access from any isolation domain.
|
/// Marked nonisolated to allow access from any isolation domain.
|
||||||
nonisolated enum SchemaVersion {
|
nonisolated enum SchemaVersion {
|
||||||
static let current: Int = 1
|
static let current: Int = 2
|
||||||
static let minimumSupported: Int = 1
|
static let minimumSupported: Int = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,27 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||||
) {
|
) {
|
||||||
// Handle CloudKit subscription notification
|
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) as? CKQueryNotification,
|
||||||
// TODO: Re-implement subscription handling for ItineraryItem changes
|
let subscriptionID = notification.subscriptionID,
|
||||||
completionHandler(.noData)
|
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
|
@MainActor
|
||||||
final class BackgroundSyncManager {
|
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
|
// MARK: - Task Identifiers
|
||||||
|
|
||||||
/// Background app refresh task - runs periodically (system decides frequency)
|
/// 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
|
task.setTaskCompleted(success: true) // Partial success - progress was saved
|
||||||
} else {
|
} else {
|
||||||
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
|
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
|
||||||
task.setTaskCompleted(success: !result.isEmpty)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
currentCancellationToken = nil
|
currentCancellationToken = nil
|
||||||
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
|
|||||||
task.setTaskCompleted(success: true) // Partial success
|
task.setTaskCompleted(success: true) // Partial success
|
||||||
} else {
|
} else {
|
||||||
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
|
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
|
||||||
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
currentCancellationToken = nil
|
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).
|
/// Clean up old game data (older than 1 year).
|
||||||
@MainActor
|
@MainActor
|
||||||
private func performCleanup(context: ModelContext) async -> Bool {
|
private func performCleanup(context: ModelContext) async -> Bool {
|
||||||
|
|||||||
@@ -114,17 +114,33 @@ actor BootstrapService {
|
|||||||
|
|
||||||
// MARK: - Public Methods
|
// 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.
|
/// This is the main entry point called at app launch.
|
||||||
@MainActor
|
@MainActor
|
||||||
func bootstrapIfNeeded(context: ModelContext) async throws {
|
func bootstrapIfNeeded(context: ModelContext) async throws {
|
||||||
let syncState = SyncState.current(in: context)
|
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 {
|
guard !syncState.bootstrapCompleted else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh bootstrap should always force a full CloudKit sync baseline.
|
||||||
|
resetSyncProgress(syncState)
|
||||||
|
|
||||||
// Clear any partial bootstrap data from a previous failed attempt
|
// Clear any partial bootstrap data from a previous failed attempt
|
||||||
try clearCanonicalData(context: context)
|
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
|
// MARK: - Bootstrap Steps
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -188,7 +228,7 @@ actor BootstrapService {
|
|||||||
capacity: jsonStadium.capacity,
|
capacity: jsonStadium.capacity,
|
||||||
yearOpened: jsonStadium.year_opened,
|
yearOpened: jsonStadium.year_opened,
|
||||||
imageURL: jsonStadium.image_url,
|
imageURL: jsonStadium.image_url,
|
||||||
sport: jsonStadium.sport,
|
sport: jsonStadium.sport.uppercased(),
|
||||||
timezoneIdentifier: jsonStadium.timezone_identifier
|
timezoneIdentifier: jsonStadium.timezone_identifier
|
||||||
)
|
)
|
||||||
context.insert(canonical)
|
context.insert(canonical)
|
||||||
@@ -265,7 +305,7 @@ actor BootstrapService {
|
|||||||
|
|
||||||
let model = LeagueStructureModel(
|
let model = LeagueStructureModel(
|
||||||
id: structure.id,
|
id: structure.id,
|
||||||
sport: structure.sport,
|
sport: structure.sport.uppercased(),
|
||||||
structureType: structureType,
|
structureType: structureType,
|
||||||
name: structure.name,
|
name: structure.name,
|
||||||
abbreviation: structure.abbreviation,
|
abbreviation: structure.abbreviation,
|
||||||
@@ -302,7 +342,7 @@ actor BootstrapService {
|
|||||||
source: .bundled,
|
source: .bundled,
|
||||||
name: jsonTeam.name,
|
name: jsonTeam.name,
|
||||||
abbreviation: jsonTeam.abbreviation,
|
abbreviation: jsonTeam.abbreviation,
|
||||||
sport: jsonTeam.sport,
|
sport: jsonTeam.sport.uppercased(),
|
||||||
city: jsonTeam.city,
|
city: jsonTeam.city,
|
||||||
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
|
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
|
||||||
conferenceId: jsonTeam.conference_id,
|
conferenceId: jsonTeam.conference_id,
|
||||||
@@ -371,6 +411,8 @@ actor BootstrapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var seenGameIds = Set<String>()
|
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 {
|
for jsonGame in games {
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
@@ -389,6 +431,21 @@ actor BootstrapService {
|
|||||||
|
|
||||||
guard let dateTime else { continue }
|
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(
|
let game = CanonicalGame(
|
||||||
canonicalId: jsonGame.canonical_id,
|
canonicalId: jsonGame.canonical_id,
|
||||||
schemaVersion: SchemaVersion.current,
|
schemaVersion: SchemaVersion.current,
|
||||||
@@ -396,9 +453,9 @@ actor BootstrapService {
|
|||||||
source: .bundled,
|
source: .bundled,
|
||||||
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
|
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
|
||||||
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
|
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
|
||||||
stadiumCanonicalId: jsonGame.stadium_canonical_id ?? "",
|
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||||
dateTime: dateTime,
|
dateTime: dateTime,
|
||||||
sport: jsonGame.sport,
|
sport: jsonGame.sport.uppercased(),
|
||||||
season: jsonGame.season,
|
season: jsonGame.season,
|
||||||
isPlayoff: jsonGame.is_playoff,
|
isPlayoff: jsonGame.is_playoff,
|
||||||
broadcastInfo: jsonGame.broadcast_info
|
broadcastInfo: jsonGame.broadcast_info
|
||||||
@@ -454,6 +511,26 @@ actor BootstrapService {
|
|||||||
try context.delete(model: CanonicalSport.self)
|
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? {
|
nonisolated private func parseISO8601(_ string: String) -> Date? {
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
formatter.formatOptions = [.withInternetDateTime]
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
|||||||
@@ -78,15 +78,17 @@ actor CanonicalSyncService {
|
|||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
let syncState = SyncState.current(in: context)
|
let syncState = SyncState.current(in: context)
|
||||||
|
|
||||||
// Prevent concurrent syncs
|
// Prevent concurrent syncs, but auto-heal stale "in progress" state.
|
||||||
guard !syncState.syncInProgress else {
|
if syncState.syncInProgress {
|
||||||
throw SyncError.syncAlreadyInProgress
|
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
|
// Check if sync is enabled
|
||||||
guard syncState.syncEnabled else {
|
guard syncState.syncEnabled else {
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
@@ -102,6 +104,10 @@ actor CanonicalSyncService {
|
|||||||
throw SyncError.cloudKitUnavailable
|
throw SyncError.cloudKitUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
SyncStatusMonitor.shared.syncStarted()
|
||||||
|
#endif
|
||||||
|
|
||||||
// Mark sync in progress
|
// Mark sync in progress
|
||||||
syncState.syncInProgress = true
|
syncState.syncInProgress = true
|
||||||
syncState.lastSyncAttempt = Date()
|
syncState.lastSyncAttempt = Date()
|
||||||
@@ -115,7 +121,7 @@ actor CanonicalSyncService {
|
|||||||
var totalSports = 0
|
var totalSports = 0
|
||||||
var totalSkippedIncompatible = 0
|
var totalSkippedIncompatible = 0
|
||||||
var totalSkippedOlder = 0
|
var totalSkippedOlder = 0
|
||||||
var wasCancelled = false
|
var nonCriticalErrors: [String] = []
|
||||||
|
|
||||||
/// Helper to save partial progress and check cancellation
|
/// Helper to save partial progress and check cancellation
|
||||||
func saveProgressAndCheckCancellation() throws -> Bool {
|
func saveProgressAndCheckCancellation() throws -> Bool {
|
||||||
@@ -129,41 +135,49 @@ actor CanonicalSyncService {
|
|||||||
do {
|
do {
|
||||||
// Sync in dependency order, checking cancellation between each entity type
|
// Sync in dependency order, checking cancellation between each entity type
|
||||||
|
|
||||||
// Stadium sync
|
// Sport sync (non-critical for schedule rendering)
|
||||||
var entityStartTime = Date()
|
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,
|
context: context,
|
||||||
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
totalStadiums = stadiums
|
totalStadiums = stadiums
|
||||||
totalSkippedIncompatible += skipIncompat1
|
totalSkippedIncompatible += skipIncompat2
|
||||||
totalSkippedOlder += skipOlder1
|
totalSkippedOlder += skipOlder2
|
||||||
syncState.lastStadiumSync = Date()
|
syncState.lastStadiumSync = entityStartTime
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
#endif
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
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()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,92 +191,119 @@ actor CanonicalSyncService {
|
|||||||
totalTeams = teams
|
totalTeams = teams
|
||||||
totalSkippedIncompatible += skipIncompat3
|
totalSkippedIncompatible += skipIncompat3
|
||||||
totalSkippedOlder += skipOlder3
|
totalSkippedOlder += skipOlder3
|
||||||
syncState.lastTeamSync = Date()
|
syncState.lastTeamSync = entityStartTime
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
|
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
#endif
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
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()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game sync
|
// Game sync
|
||||||
entityStartTime = Date()
|
entityStartTime = Date()
|
||||||
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
let (games, skipIncompat4, skipOlder4) = try await syncGames(
|
||||||
context: context,
|
context: context,
|
||||||
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
totalGames = games
|
totalGames = games
|
||||||
totalSkippedIncompatible += skipIncompat6
|
totalSkippedIncompatible += skipIncompat4
|
||||||
totalSkippedOlder += skipOlder6
|
totalSkippedOlder += skipOlder4
|
||||||
syncState.lastGameSync = Date()
|
syncState.lastGameSync = entityStartTime
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
||||||
#endif
|
#endif
|
||||||
if try saveProgressAndCheckCancellation() {
|
if try saveProgressAndCheckCancellation() {
|
||||||
wasCancelled = true
|
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sport sync
|
// League Structure sync (non-critical for schedule rendering)
|
||||||
entityStartTime = Date()
|
entityStartTime = Date()
|
||||||
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
do {
|
||||||
context: context,
|
let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
|
||||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
context: context,
|
||||||
cancellationToken: cancellationToken
|
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||||
)
|
cancellationToken: cancellationToken
|
||||||
totalSports = sports
|
)
|
||||||
totalSkippedIncompatible += skipIncompat7
|
totalLeagueStructures = leagueStructures
|
||||||
totalSkippedOlder += skipOlder7
|
totalSkippedIncompatible += skipIncompat5
|
||||||
syncState.lastSportSync = Date()
|
totalSkippedOlder += skipOlder5
|
||||||
#if DEBUG
|
syncState.lastLeagueStructureSync = entityStartTime
|
||||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
#if DEBUG
|
||||||
#endif
|
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
|
// Mark sync successful - clear per-entity timestamps since full sync completed
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
syncState.lastSuccessfulSync = Date()
|
syncState.lastSuccessfulSync = startTime
|
||||||
syncState.lastSyncError = nil
|
syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
|
||||||
syncState.consecutiveFailures = 0
|
syncState.consecutiveFailures = 0
|
||||||
|
syncState.syncPausedReason = nil
|
||||||
// Clear per-entity timestamps - they're only needed for partial recovery
|
// Clear per-entity timestamps - they're only needed for partial recovery
|
||||||
syncState.lastStadiumSync = nil
|
syncState.lastStadiumSync = nil
|
||||||
syncState.lastTeamSync = nil
|
syncState.lastTeamSync = nil
|
||||||
@@ -305,12 +346,24 @@ actor CanonicalSyncService {
|
|||||||
// Mark sync failed
|
// Mark sync failed
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
syncState.lastSyncError = error.localizedDescription
|
syncState.lastSyncError = error.localizedDescription
|
||||||
syncState.consecutiveFailures += 1
|
|
||||||
|
|
||||||
// Pause sync after too many failures
|
if isTransientCloudKitError(error) {
|
||||||
if syncState.consecutiveFailures >= 5 {
|
// Network/account hiccups should not permanently degrade sync health.
|
||||||
syncState.syncEnabled = false
|
syncState.consecutiveFailures = 0
|
||||||
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
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()
|
try? context.save()
|
||||||
@@ -347,6 +400,22 @@ actor CanonicalSyncService {
|
|||||||
try? context.save()
|
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
|
// MARK: - Individual Sync Methods
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -388,6 +457,12 @@ actor CanonicalSyncService {
|
|||||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||||
// Single call for all teams with delta sync
|
// Single call for all teams with delta sync
|
||||||
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
|
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 updated = 0
|
||||||
var skippedIncompatible = 0
|
var skippedIncompatible = 0
|
||||||
@@ -399,6 +474,7 @@ actor CanonicalSyncService {
|
|||||||
syncTeam.team,
|
syncTeam.team,
|
||||||
canonicalId: syncTeam.canonicalId,
|
canonicalId: syncTeam.canonicalId,
|
||||||
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
||||||
|
validStadiumIds: &validStadiumIds,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -409,6 +485,10 @@ actor CanonicalSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if skippedIncompatible > 0 {
|
||||||
|
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
|
||||||
|
}
|
||||||
|
|
||||||
return (updated, skippedIncompatible, skippedOlder)
|
return (updated, skippedIncompatible, skippedOlder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +500,19 @@ actor CanonicalSyncService {
|
|||||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||||
// Delta sync: nil = all games, Date = only modified since
|
// Delta sync: nil = all games, Date = only modified since
|
||||||
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
|
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 updated = 0
|
||||||
var skippedIncompatible = 0
|
var skippedIncompatible = 0
|
||||||
@@ -433,6 +526,9 @@ actor CanonicalSyncService {
|
|||||||
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
||||||
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
||||||
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
||||||
|
validTeamIds: validTeamIds,
|
||||||
|
teamStadiumByTeamId: teamStadiumByTeamId,
|
||||||
|
validStadiumIds: &validStadiumIds,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -443,6 +539,10 @@ actor CanonicalSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if skippedIncompatible > 0 {
|
||||||
|
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
|
||||||
|
}
|
||||||
|
|
||||||
return (updated, skippedIncompatible, skippedOlder)
|
return (updated, skippedIncompatible, skippedOlder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +685,9 @@ actor CanonicalSyncService {
|
|||||||
existing.timezoneIdentifier = remote.timeZoneIdentifier
|
existing.timezoneIdentifier = remote.timeZoneIdentifier
|
||||||
existing.source = .cloudKit
|
existing.source = .cloudKit
|
||||||
existing.lastModified = Date()
|
existing.lastModified = Date()
|
||||||
|
existing.deprecatedAt = nil
|
||||||
|
existing.deprecationReason = nil
|
||||||
|
existing.replacedByCanonicalId = nil
|
||||||
|
|
||||||
// Restore user fields
|
// Restore user fields
|
||||||
existing.userNickname = savedNickname
|
existing.userNickname = savedNickname
|
||||||
@@ -620,7 +723,8 @@ actor CanonicalSyncService {
|
|||||||
private func mergeTeam(
|
private func mergeTeam(
|
||||||
_ remote: Team,
|
_ remote: Team,
|
||||||
canonicalId: String,
|
canonicalId: String,
|
||||||
stadiumCanonicalId: String,
|
stadiumCanonicalId: String?,
|
||||||
|
validStadiumIds: inout Set<String>,
|
||||||
context: ModelContext
|
context: ModelContext
|
||||||
) throws -> MergeResult {
|
) throws -> MergeResult {
|
||||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||||
@@ -628,7 +732,32 @@ actor CanonicalSyncService {
|
|||||||
)
|
)
|
||||||
let existing = try context.fetch(descriptor).first
|
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 {
|
if let existing = existing {
|
||||||
// Preserve user fields
|
// Preserve user fields
|
||||||
@@ -640,7 +769,7 @@ actor CanonicalSyncService {
|
|||||||
existing.abbreviation = remote.abbreviation
|
existing.abbreviation = remote.abbreviation
|
||||||
existing.sport = remote.sport.rawValue
|
existing.sport = remote.sport.rawValue
|
||||||
existing.city = remote.city
|
existing.city = remote.city
|
||||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||||
existing.logoURL = remote.logoURL?.absoluteString
|
existing.logoURL = remote.logoURL?.absoluteString
|
||||||
existing.primaryColor = remote.primaryColor
|
existing.primaryColor = remote.primaryColor
|
||||||
existing.secondaryColor = remote.secondaryColor
|
existing.secondaryColor = remote.secondaryColor
|
||||||
@@ -648,6 +777,9 @@ actor CanonicalSyncService {
|
|||||||
existing.divisionId = remote.divisionId
|
existing.divisionId = remote.divisionId
|
||||||
existing.source = .cloudKit
|
existing.source = .cloudKit
|
||||||
existing.lastModified = Date()
|
existing.lastModified = Date()
|
||||||
|
existing.deprecatedAt = nil
|
||||||
|
existing.deprecationReason = nil
|
||||||
|
existing.relocatedToCanonicalId = nil
|
||||||
|
|
||||||
// Restore user fields
|
// Restore user fields
|
||||||
existing.userNickname = savedNickname
|
existing.userNickname = savedNickname
|
||||||
@@ -666,7 +798,7 @@ actor CanonicalSyncService {
|
|||||||
abbreviation: remote.abbreviation,
|
abbreviation: remote.abbreviation,
|
||||||
sport: remote.sport.rawValue,
|
sport: remote.sport.rawValue,
|
||||||
city: remote.city,
|
city: remote.city,
|
||||||
stadiumCanonicalId: stadiumCanonicalId,
|
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||||
logoURL: remote.logoURL?.absoluteString,
|
logoURL: remote.logoURL?.absoluteString,
|
||||||
primaryColor: remote.primaryColor,
|
primaryColor: remote.primaryColor,
|
||||||
secondaryColor: remote.secondaryColor,
|
secondaryColor: remote.secondaryColor,
|
||||||
@@ -684,7 +816,10 @@ actor CanonicalSyncService {
|
|||||||
canonicalId: String,
|
canonicalId: String,
|
||||||
homeTeamCanonicalId: String,
|
homeTeamCanonicalId: String,
|
||||||
awayTeamCanonicalId: String,
|
awayTeamCanonicalId: String,
|
||||||
stadiumCanonicalId: String,
|
stadiumCanonicalId: String?,
|
||||||
|
validTeamIds: Set<String>,
|
||||||
|
teamStadiumByTeamId: [String: String],
|
||||||
|
validStadiumIds: inout Set<String>,
|
||||||
context: ModelContext
|
context: ModelContext
|
||||||
) throws -> MergeResult {
|
) throws -> MergeResult {
|
||||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||||
@@ -692,7 +827,56 @@ actor CanonicalSyncService {
|
|||||||
)
|
)
|
||||||
let existing = try context.fetch(descriptor).first
|
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 {
|
if let existing = existing {
|
||||||
// Preserve user fields
|
// Preserve user fields
|
||||||
@@ -700,9 +884,9 @@ actor CanonicalSyncService {
|
|||||||
let savedNotes = existing.userNotes
|
let savedNotes = existing.userNotes
|
||||||
|
|
||||||
// Update system fields
|
// Update system fields
|
||||||
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
existing.homeTeamCanonicalId = resolvedHomeTeamId
|
||||||
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
existing.awayTeamCanonicalId = resolvedAwayTeamId
|
||||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||||
existing.dateTime = remote.dateTime
|
existing.dateTime = remote.dateTime
|
||||||
existing.sport = remote.sport.rawValue
|
existing.sport = remote.sport.rawValue
|
||||||
existing.season = remote.season
|
existing.season = remote.season
|
||||||
@@ -710,6 +894,9 @@ actor CanonicalSyncService {
|
|||||||
existing.broadcastInfo = remote.broadcastInfo
|
existing.broadcastInfo = remote.broadcastInfo
|
||||||
existing.source = .cloudKit
|
existing.source = .cloudKit
|
||||||
existing.lastModified = Date()
|
existing.lastModified = Date()
|
||||||
|
existing.deprecatedAt = nil
|
||||||
|
existing.deprecationReason = nil
|
||||||
|
existing.rescheduledToCanonicalId = nil
|
||||||
|
|
||||||
// Restore user fields
|
// Restore user fields
|
||||||
existing.userAttending = savedAttending
|
existing.userAttending = savedAttending
|
||||||
@@ -724,9 +911,9 @@ actor CanonicalSyncService {
|
|||||||
schemaVersion: SchemaVersion.current,
|
schemaVersion: SchemaVersion.current,
|
||||||
lastModified: Date(),
|
lastModified: Date(),
|
||||||
source: .cloudKit,
|
source: .cloudKit,
|
||||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
homeTeamCanonicalId: resolvedHomeTeamId,
|
||||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
awayTeamCanonicalId: resolvedAwayTeamId,
|
||||||
stadiumCanonicalId: stadiumCanonicalId,
|
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||||
dateTime: remote.dateTime,
|
dateTime: remote.dateTime,
|
||||||
sport: remote.sport.rawValue,
|
sport: remote.sport.rawValue,
|
||||||
season: remote.season,
|
season: remote.season,
|
||||||
@@ -895,4 +1082,44 @@ actor CanonicalSyncService {
|
|||||||
return .applied
|
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 {
|
actor CloudKitService {
|
||||||
static let shared = 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 container: CKContainer
|
||||||
private let publicDatabase: CKDatabase
|
private let publicDatabase: CKDatabase
|
||||||
|
|
||||||
/// Maximum records per CloudKit query (400 is the default limit)
|
/// Maximum records per CloudKit query (400 is the default limit)
|
||||||
private let recordsPerPage = 400
|
private let recordsPerPage = 400
|
||||||
|
/// Re-fetch a small overlap window to avoid missing updates around sync boundaries.
|
||||||
|
private let deltaOverlapSeconds: TimeInterval = 120
|
||||||
|
|
||||||
private init() {
|
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
|
self.publicDatabase = container.publicCloudDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +124,8 @@ actor CloudKitService {
|
|||||||
) async throws -> [CKRecord] {
|
) async throws -> [CKRecord] {
|
||||||
var allRecords: [CKRecord] = []
|
var allRecords: [CKRecord] = []
|
||||||
var cursor: CKQueryOperation.Cursor?
|
var cursor: CKQueryOperation.Cursor?
|
||||||
|
var partialFailureCount = 0
|
||||||
|
var firstPartialFailure: Error?
|
||||||
|
|
||||||
// First page
|
// First page
|
||||||
let (firstResults, firstCursor) = try await publicDatabase.records(
|
let (firstResults, firstCursor) = try await publicDatabase.records(
|
||||||
@@ -91,8 +134,14 @@ actor CloudKitService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for result in firstResults {
|
for result in firstResults {
|
||||||
if case .success(let record) = result.1 {
|
switch result.1 {
|
||||||
|
case .success(let record):
|
||||||
allRecords.append(record)
|
allRecords.append(record)
|
||||||
|
case .failure(let error):
|
||||||
|
partialFailureCount += 1
|
||||||
|
if firstPartialFailure == nil {
|
||||||
|
firstPartialFailure = error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor = firstCursor
|
cursor = firstCursor
|
||||||
@@ -110,16 +159,51 @@ actor CloudKitService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for result in results {
|
for result in results {
|
||||||
if case .success(let record) = result.1 {
|
switch result.1 {
|
||||||
|
case .success(let record):
|
||||||
allRecords.append(record)
|
allRecords.append(record)
|
||||||
|
case .failure(let error):
|
||||||
|
partialFailureCount += 1
|
||||||
|
if firstPartialFailure == nil {
|
||||||
|
firstPartialFailure = error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor = nextCursor
|
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
|
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)
|
// MARK: - Sync Types (include canonical IDs from CloudKit)
|
||||||
|
|
||||||
struct SyncStadium {
|
struct SyncStadium {
|
||||||
@@ -130,7 +214,7 @@ actor CloudKitService {
|
|||||||
struct SyncTeam {
|
struct SyncTeam {
|
||||||
let team: Team
|
let team: Team
|
||||||
let canonicalId: String
|
let canonicalId: String
|
||||||
let stadiumCanonicalId: String
|
let stadiumCanonicalId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SyncGame {
|
struct SyncGame {
|
||||||
@@ -138,23 +222,29 @@ actor CloudKitService {
|
|||||||
let canonicalId: String
|
let canonicalId: String
|
||||||
let homeTeamCanonicalId: String
|
let homeTeamCanonicalId: String
|
||||||
let awayTeamCanonicalId: String
|
let awayTeamCanonicalId: String
|
||||||
let stadiumCanonicalId: String
|
let stadiumCanonicalId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Availability Check
|
// MARK: - Availability Check
|
||||||
|
|
||||||
func isAvailable() async -> Bool {
|
func isAvailable() async -> Bool {
|
||||||
let status = await checkAccountStatus()
|
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 {
|
func checkAvailabilityWithError() async throws {
|
||||||
let status = await checkAccountStatus()
|
let status = await checkAccountStatus()
|
||||||
switch status {
|
switch status {
|
||||||
case .available:
|
case .available, .noAccount:
|
||||||
return
|
return
|
||||||
case .noAccount:
|
|
||||||
throw CloudKitError.notSignedIn
|
|
||||||
case .restricted:
|
case .restricted:
|
||||||
throw CloudKitError.permissionDenied
|
throw CloudKitError.permissionDenied
|
||||||
case .couldNotDetermine:
|
case .couldNotDetermine:
|
||||||
@@ -268,23 +358,39 @@ actor CloudKitService {
|
|||||||
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
|
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching stadiums modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL stadiums (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
||||||
|
|
||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
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)
|
let ckStadium = CKStadium(record: record)
|
||||||
guard let stadium = ckStadium.stadium,
|
guard let stadium = ckStadium.stadium,
|
||||||
let canonicalId = ckStadium.canonicalId
|
let canonicalId = ckStadium.canonicalId
|
||||||
else { return nil }
|
else {
|
||||||
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
|
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
|
/// 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.
|
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
|
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching teams modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL teams (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
||||||
|
|
||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
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)
|
let ckTeam = CKTeam(record: record)
|
||||||
guard let team = ckTeam.team,
|
guard let team = ckTeam.team,
|
||||||
let canonicalId = ckTeam.canonicalId,
|
let canonicalId = ckTeam.canonicalId
|
||||||
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
|
else {
|
||||||
else { return nil }
|
skipped += 1
|
||||||
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
|
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
|
/// Fetch games for sync operations
|
||||||
@@ -319,9 +448,9 @@ actor CloudKitService {
|
|||||||
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())")
|
log.log("☁️ [CK] Fetching games modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
log.log("☁️ [CK] Fetching ALL games (full sync)")
|
log.log("☁️ [CK] Fetching ALL games (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
@@ -334,23 +463,28 @@ actor CloudKitService {
|
|||||||
var validGames: [SyncGame] = []
|
var validGames: [SyncGame] = []
|
||||||
var skippedMissingIds = 0
|
var skippedMissingIds = 0
|
||||||
var skippedInvalidGame = 0
|
var skippedInvalidGame = 0
|
||||||
|
var missingStadiumRef = 0
|
||||||
|
|
||||||
for record in records {
|
for record in records {
|
||||||
let ckGame = CKGame(record: record)
|
let ckGame = CKGame(record: record)
|
||||||
|
|
||||||
guard let canonicalId = ckGame.canonicalId,
|
guard let canonicalId = ckGame.canonicalId,
|
||||||
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
||||||
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId
|
||||||
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
|
||||||
else {
|
else {
|
||||||
skippedMissingIds += 1
|
skippedMissingIds += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
||||||
|
if stadiumCanonicalId == nil {
|
||||||
|
missingStadiumRef += 1
|
||||||
|
}
|
||||||
|
|
||||||
guard let game = ckGame.game(
|
guard let game = ckGame.game(
|
||||||
homeTeamId: homeTeamCanonicalId,
|
homeTeamId: homeTeamCanonicalId,
|
||||||
awayTeamId: awayTeamCanonicalId,
|
awayTeamId: awayTeamCanonicalId,
|
||||||
stadiumId: stadiumCanonicalId
|
stadiumId: stadiumCanonicalId ?? "stadium_placeholder_\(canonicalId)"
|
||||||
) else {
|
) else {
|
||||||
skippedInvalidGame += 1
|
skippedInvalidGame += 1
|
||||||
continue
|
continue
|
||||||
@@ -366,6 +500,10 @@ actor CloudKitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)")
|
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
|
// Log sport breakdown
|
||||||
var bySport: [String: Int] = [:]
|
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.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
|
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching league structures modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL league structures (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
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)
|
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
|
var parsed: [LeagueStructureModel] = []
|
||||||
return CKLeagueStructure(record: record).toModel()
|
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.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
|
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching team aliases modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL team aliases (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
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)
|
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
|
var parsed: [TeamAlias] = []
|
||||||
return CKTeamAlias(record: record).toModel()
|
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.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
|
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching stadium aliases modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL stadium aliases (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
|
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)
|
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
|
var parsed: [StadiumAlias] = []
|
||||||
return CKStadiumAlias(record: record).toModel()
|
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.
|
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
|
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
log.log("☁️ [CK] Fetching sports modified since \(deltaStart.formatted()) (overlap window applied)")
|
||||||
|
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL sports (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
||||||
|
|
||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
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
|
var parsed: [CanonicalSport] = []
|
||||||
return CKSport(record: record).toCanonical()
|
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
|
// MARK: - Sync Status
|
||||||
@@ -542,83 +748,130 @@ actor CloudKitService {
|
|||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.game,
|
recordType: CKRecordType.game,
|
||||||
predicate: NSPredicate(value: true),
|
predicate: NSPredicate(value: true),
|
||||||
subscriptionID: "game-updates",
|
subscriptionID: Self.gameUpdatesSubscriptionID,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
let notification = CKSubscription.NotificationInfo()
|
let notification = CKSubscription.NotificationInfo()
|
||||||
notification.shouldSendContentAvailable = true
|
notification.shouldSendContentAvailable = true
|
||||||
subscription.notificationInfo = notification
|
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 {
|
func subscribeToLeagueStructureUpdates() async throws {
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.leagueStructure,
|
recordType: CKRecordType.leagueStructure,
|
||||||
predicate: NSPredicate(value: true),
|
predicate: NSPredicate(value: true),
|
||||||
subscriptionID: "league-structure-updates",
|
subscriptionID: Self.leagueStructureUpdatesSubscriptionID,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
let notification = CKSubscription.NotificationInfo()
|
let notification = CKSubscription.NotificationInfo()
|
||||||
notification.shouldSendContentAvailable = true
|
notification.shouldSendContentAvailable = true
|
||||||
subscription.notificationInfo = notification
|
subscription.notificationInfo = notification
|
||||||
|
|
||||||
try await publicDatabase.save(subscription)
|
try await saveSubscriptionIfNeeded(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeToTeamAliasUpdates() async throws {
|
func subscribeToTeamAliasUpdates() async throws {
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.teamAlias,
|
recordType: CKRecordType.teamAlias,
|
||||||
predicate: NSPredicate(value: true),
|
predicate: NSPredicate(value: true),
|
||||||
subscriptionID: "team-alias-updates",
|
subscriptionID: Self.teamAliasUpdatesSubscriptionID,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
let notification = CKSubscription.NotificationInfo()
|
let notification = CKSubscription.NotificationInfo()
|
||||||
notification.shouldSendContentAvailable = true
|
notification.shouldSendContentAvailable = true
|
||||||
subscription.notificationInfo = notification
|
subscription.notificationInfo = notification
|
||||||
|
|
||||||
try await publicDatabase.save(subscription)
|
try await saveSubscriptionIfNeeded(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeToStadiumAliasUpdates() async throws {
|
func subscribeToStadiumAliasUpdates() async throws {
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.stadiumAlias,
|
recordType: CKRecordType.stadiumAlias,
|
||||||
predicate: NSPredicate(value: true),
|
predicate: NSPredicate(value: true),
|
||||||
subscriptionID: "stadium-alias-updates",
|
subscriptionID: Self.stadiumAliasUpdatesSubscriptionID,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
let notification = CKSubscription.NotificationInfo()
|
let notification = CKSubscription.NotificationInfo()
|
||||||
notification.shouldSendContentAvailable = true
|
notification.shouldSendContentAvailable = true
|
||||||
subscription.notificationInfo = notification
|
subscription.notificationInfo = notification
|
||||||
|
|
||||||
try await publicDatabase.save(subscription)
|
try await saveSubscriptionIfNeeded(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeToSportUpdates() async throws {
|
func subscribeToSportUpdates() async throws {
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.sport,
|
recordType: CKRecordType.sport,
|
||||||
predicate: NSPredicate(value: true),
|
predicate: NSPredicate(value: true),
|
||||||
subscriptionID: "sport-updates",
|
subscriptionID: Self.sportUpdatesSubscriptionID,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
let notification = CKSubscription.NotificationInfo()
|
let notification = CKSubscription.NotificationInfo()
|
||||||
notification.shouldSendContentAvailable = true
|
notification.shouldSendContentAvailable = true
|
||||||
subscription.notificationInfo = notification
|
subscription.notificationInfo = notification
|
||||||
|
|
||||||
try await publicDatabase.save(subscription)
|
try await saveSubscriptionIfNeeded(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to all canonical data updates
|
/// Subscribe to all canonical data updates
|
||||||
func subscribeToAllUpdates() async throws {
|
func subscribeToAllUpdates() async throws {
|
||||||
try await subscribeToScheduleUpdates()
|
try await subscribeToScheduleUpdates()
|
||||||
|
try await subscribeToTeamUpdates()
|
||||||
|
try await subscribeToStadiumUpdates()
|
||||||
try await subscribeToLeagueStructureUpdates()
|
try await subscribeToLeagueStructureUpdates()
|
||||||
try await subscribeToTeamAliasUpdates()
|
try await subscribeToTeamAliasUpdates()
|
||||||
try await subscribeToStadiumAliasUpdates()
|
try await subscribeToStadiumAliasUpdates()
|
||||||
try await subscribeToSportUpdates()
|
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 richGames: [RichGame] = []
|
||||||
var droppedGames: [(game: Game, reason: String)] = []
|
var droppedGames: [(game: Game, reason: String)] = []
|
||||||
|
var stadiumFallbacksApplied = 0
|
||||||
|
|
||||||
for game in games {
|
for game in games {
|
||||||
let homeTeam = teamsById[game.homeTeamId]
|
let homeTeam = teamsById[game.homeTeamId]
|
||||||
let awayTeam = teamsById[game.awayTeamId]
|
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] = []
|
var reasons: [String] = []
|
||||||
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
|
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
|
||||||
if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") }
|
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: ", "))"))
|
droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))"))
|
||||||
continue
|
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 {
|
if !droppedGames.isEmpty {
|
||||||
@@ -248,6 +252,9 @@ final class AppDataProvider: ObservableObject {
|
|||||||
print("⚠️ [DATA] ... and \(droppedGames.count - 10) more")
|
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")
|
print("🎮 [DATA] Returning \(richGames.count) rich games")
|
||||||
return richGames
|
return richGames
|
||||||
@@ -260,7 +267,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
return games.compactMap { game in
|
return games.compactMap { game in
|
||||||
guard let homeTeam = teamsById[game.homeTeamId],
|
guard let homeTeam = teamsById[game.homeTeamId],
|
||||||
let awayTeam = teamsById[game.awayTeamId],
|
let awayTeam = teamsById[game.awayTeamId],
|
||||||
let stadium = stadiumsById[game.stadiumId] else {
|
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
@@ -270,7 +277,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
func richGame(from game: Game) -> RichGame? {
|
func richGame(from game: Game) -> RichGame? {
|
||||||
guard let homeTeam = teamsById[game.homeTeamId],
|
guard let homeTeam = teamsById[game.homeTeamId],
|
||||||
let awayTeam = teamsById[game.awayTeamId],
|
let awayTeam = teamsById[game.awayTeamId],
|
||||||
let stadium = stadiumsById[game.stadiumId] else {
|
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
@@ -299,13 +306,27 @@ final class AppDataProvider: ObservableObject {
|
|||||||
let game = canonical.toDomain()
|
let game = canonical.toDomain()
|
||||||
guard let homeTeam = teamsById[game.homeTeamId],
|
guard let homeTeam = teamsById[game.homeTeamId],
|
||||||
let awayTeam = teamsById[game.awayTeamId],
|
let awayTeam = teamsById[game.awayTeamId],
|
||||||
let stadium = stadiumsById[game.stadiumId] else {
|
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
|
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
|
||||||
}
|
}
|
||||||
return teamGames
|
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
|
// MARK: - Errors
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CloudKit
|
|||||||
actor ItineraryItemService {
|
actor ItineraryItemService {
|
||||||
static let shared = 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 var database: CKDatabase { container.privateCloudDatabase }
|
||||||
|
|
||||||
private let recordType = "ItineraryItem"
|
private let recordType = "ItineraryItem"
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ actor PollService {
|
|||||||
private var pollSubscriptionID: CKSubscription.ID?
|
private var pollSubscriptionID: CKSubscription.ID?
|
||||||
|
|
||||||
private init() {
|
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
|
self.publicDatabase = container.publicCloudDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import os
|
|||||||
|
|
||||||
/// Protocol for cancellation tokens checked between sync pages
|
/// Protocol for cancellation tokens checked between sync pages
|
||||||
protocol SyncCancellationToken: Sendable {
|
protocol SyncCancellationToken: Sendable {
|
||||||
var isCancelled: Bool { get }
|
nonisolated var isCancelled: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concrete cancellation token for background tasks
|
/// Concrete cancellation token for background tasks
|
||||||
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
|
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
|
||||||
private let lock = OSAllocatedUnfairLock(initialState: false)
|
private let lock = OSAllocatedUnfairLock(initialState: false)
|
||||||
|
|
||||||
var isCancelled: Bool {
|
nonisolated var isCancelled: Bool {
|
||||||
lock.withLock { $0 }
|
lock.withLock { $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class SyncLogger {
|
nonisolated final class SyncLogger {
|
||||||
static let shared = SyncLogger()
|
static let shared = SyncLogger()
|
||||||
|
|
||||||
private let fileURL: URL
|
private let fileURL: URL
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class VisitPhotoService {
|
|||||||
|
|
||||||
init(modelContext: ModelContext) {
|
init(modelContext: ModelContext) {
|
||||||
self.modelContext = modelContext
|
self.modelContext = modelContext
|
||||||
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
|
self.container = CKContainer.default()
|
||||||
self.privateDatabase = container.privateCloudDatabase
|
self.privateDatabase = container.privateCloudDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ struct SettingsView: View {
|
|||||||
@State private var showResetConfirmation = false
|
@State private var showResetConfirmation = false
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
@State private var showOnboardingPaywall = false
|
@State private var showOnboardingPaywall = false
|
||||||
|
@State private var showSyncLogs = false
|
||||||
|
@State private var isSyncActionInProgress = false
|
||||||
|
@State private var syncActionMessage: String?
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@State private var selectedSyncStatus: EntitySyncStatus?
|
@State private var selectedSyncStatus: EntitySyncStatus?
|
||||||
@State private var showSyncLogs = false
|
|
||||||
@State private var exporter = DebugShareExporter()
|
@State private var exporter = DebugShareExporter()
|
||||||
@State private var showExportProgress = false
|
@State private var showExportProgress = false
|
||||||
#endif
|
#endif
|
||||||
@@ -39,6 +41,9 @@ struct SettingsView: View {
|
|||||||
// Travel Preferences
|
// Travel Preferences
|
||||||
travelSection
|
travelSection
|
||||||
|
|
||||||
|
// Data Sync
|
||||||
|
syncHealthSection
|
||||||
|
|
||||||
// Privacy
|
// Privacy
|
||||||
privacySection
|
privacySection
|
||||||
|
|
||||||
@@ -72,6 +77,17 @@ struct SettingsView: View {
|
|||||||
.sheet(isPresented: $showOnboardingPaywall) {
|
.sheet(isPresented: $showOnboardingPaywall) {
|
||||||
OnboardingPaywallView(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
|
// MARK: - Appearance Section
|
||||||
@@ -370,6 +386,97 @@ struct SettingsView: View {
|
|||||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
.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
|
// MARK: - Debug Section
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -548,9 +655,6 @@ struct SettingsView: View {
|
|||||||
.sheet(item: $selectedSyncStatus) { status in
|
.sheet(item: $selectedSyncStatus) { status in
|
||||||
SyncStatusDetailSheet(status: status)
|
SyncStatusDetailSheet(status: status)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSyncLogs) {
|
|
||||||
SyncLogViewerSheet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -633,6 +737,23 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// 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 {
|
private func sportColor(for sport: Sport) -> Color {
|
||||||
sport.themeColor
|
sport.themeColor
|
||||||
}
|
}
|
||||||
@@ -775,6 +896,8 @@ private struct DetailRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Sheet to view sync logs
|
/// Sheet to view sync logs
|
||||||
struct SyncLogViewerSheet: View {
|
struct SyncLogViewerSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -837,5 +960,3 @@ struct SyncLogViewerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>production</string>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>iCloud.com.88oakapps.SportsTime</string>
|
<string>iCloud.com.88oakapps.SportsTime</string>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ struct SportsTimeApp: App {
|
|||||||
private var transactionListener: Task<Void, Never>?
|
private var transactionListener: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Configure sync manager immediately so push/background triggers can sync.
|
||||||
|
BackgroundSyncManager.shared.configure(with: sharedModelContainer)
|
||||||
|
|
||||||
// Register background tasks BEFORE app finishes launching
|
// Register background tasks BEFORE app finishes launching
|
||||||
// This must happen synchronously in init or applicationDidFinishLaunching
|
// This must happen synchronously in init or applicationDidFinishLaunching
|
||||||
BackgroundSyncManager.shared.registerTasks()
|
BackgroundSyncManager.shared.registerTasks()
|
||||||
@@ -181,6 +184,9 @@ struct BootstrappedContentView: View {
|
|||||||
// 4. Load data from SwiftData into memory
|
// 4. Load data from SwiftData into memory
|
||||||
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
|
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
|
||||||
await AppDataProvider.shared.loadInitialData()
|
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.teams.count) teams")
|
||||||
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
|
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
|
||||||
|
|
||||||
@@ -207,10 +213,15 @@ struct BootstrappedContentView: View {
|
|||||||
// 9. Schedule background tasks for future syncs
|
// 9. Schedule background tasks for future syncs
|
||||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
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)
|
// 10. Background: Try to refresh from CloudKit (non-blocking)
|
||||||
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
||||||
Task.detached(priority: .background) {
|
Task(priority: .background) {
|
||||||
await self.performBackgroundSync(context: context)
|
await self.performBackgroundSync(context: self.modelContainer.mainContext)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.hasCompletedInitialSync = true
|
self.hasCompletedInitialSync = true
|
||||||
}
|
}
|
||||||
@@ -227,9 +238,16 @@ struct BootstrappedContentView: View {
|
|||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
log.log("🔄 [SYNC] Starting background sync...")
|
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)
|
let syncState = SyncState.current(in: context)
|
||||||
if syncState.syncInProgress {
|
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")
|
log.log("⚠️ [SYNC] Resetting stale syncInProgress flag")
|
||||||
syncState.syncInProgress = false
|
syncState.syncInProgress = false
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|||||||
211
docs/CODEX_SHARE_CARD_REDESIGN_PROMPT.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Prompt: Redesign All Shareable Cards — Premium Sports-Media Aesthetic
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
This is an iOS SwiftUI app (`SportsTime.xcodeproj`) for planning multi-stop sports road trips. The app generates shareable image cards (1080x1920 Instagram-story size) for achievements, stadium progress, and trip summaries. These cards are rendered via SwiftUI `ImageRenderer` and exported as UIImages.
|
||||||
|
|
||||||
|
**Build command:**
|
||||||
|
```bash
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Modify (all under `SportsTime/Export/Sharing/`)
|
||||||
|
|
||||||
|
1. `ShareableContent.swift` — Theme definition and shared types
|
||||||
|
2. `ShareCardSportBackground.swift` — Card backgrounds
|
||||||
|
3. `ShareCardComponents.swift` — Reusable header, footer, stats, progress ring, map snapshot generator
|
||||||
|
4. `AchievementCardGenerator.swift` — 4 achievement card types + badge + confetti
|
||||||
|
5. `ProgressCardGenerator.swift` — Stadium progress card
|
||||||
|
6. `TripCardGenerator.swift` — Trip summary card
|
||||||
|
|
||||||
|
## Current State (What Exists)
|
||||||
|
|
||||||
|
The cards currently work but look generic — flat linear gradients, basic system fonts, simple circles, scattered sport icons. The goal is to make them look like ESPN broadcast graphics meets a premium travel app.
|
||||||
|
|
||||||
|
### Key Types You'll Reference (defined elsewhere, don't modify):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Sport enum — .mlb, .nba, .nhl, .nfl, .mls, .wnba, .nwsl
|
||||||
|
// Each has: .iconName (SF Symbol), .rawValue ("MLB"), .displayName, .color
|
||||||
|
|
||||||
|
// ShareTheme — has: gradientColors, accentColor, textColor, secondaryTextColor, useDarkMap
|
||||||
|
// 8 preset themes: .dark, .light, .midnight, .forest, .sunset, .berry, .ocean, .slate
|
||||||
|
|
||||||
|
// ShareCardDimensions — cardSize (1080x1920), mapSnapshotSize, routeMapSize, padding (60)
|
||||||
|
|
||||||
|
// AchievementDefinition — has: name, description, iconName, iconColor, sport, category
|
||||||
|
// AchievementProgress — has: definition, earnedAt (Date?)
|
||||||
|
|
||||||
|
// LeagueProgress — has: sport, visitedStadiums, totalStadiums, completionPercentage,
|
||||||
|
// stadiumsVisited: [Stadium], stadiumsRemaining: [Stadium]
|
||||||
|
|
||||||
|
// Trip — has: stops, uniqueSports, cities, totalDistanceMiles, totalGames, formattedDateRange
|
||||||
|
// TripStop — has: city, coordinate, arrivalDate, departureDate
|
||||||
|
|
||||||
|
// Color(hex:) initializer exists in Theme.swift
|
||||||
|
|
||||||
|
// Stadium — has: latitude, longitude
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
**Aesthetic: "Stadium Broadcast"** — bold condensed type, layered depth, geometric sport motifs, premium stat presentation. Each card should feel like a TV broadcast graphic.
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- Strong typographic hierarchy: `.heavy` for headers, `.black` + `.rounded` for numbers, `.light` for descriptions
|
||||||
|
- Three-layer backgrounds: base gradient → geometric pattern → vignette overlay
|
||||||
|
- Sport-specific geometric patterns (diamond lattice for MLB, hexagonal for NBA, ice-crystal for NHL)
|
||||||
|
- "Glass panel" containers: `surfaceColor` fill (textColor at 0.08 opacity) + `borderColor` stroke (textColor at 0.15)
|
||||||
|
- Gold/metallic accents (`#FFD700`, `#B8860B`) for milestones and 100% completion
|
||||||
|
- Tracked ALL CAPS labels for category text
|
||||||
|
- Maps framed with themed borders and subtle shadows
|
||||||
|
|
||||||
|
## Detailed Spec Per File
|
||||||
|
|
||||||
|
### 1. ShareableContent.swift — Add Theme Helper Extensions
|
||||||
|
|
||||||
|
Add these computed properties to `ShareTheme` (after the existing `theme(byId:)` method):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var surfaceColor: Color // textColor.opacity(0.08) — glass panel fill
|
||||||
|
var borderColor: Color // textColor.opacity(0.15) — panel borders
|
||||||
|
var glowColor: Color // accentColor.opacity(0.4) — for glow effects
|
||||||
|
var highlightGradient: [Color] // [accentColor, accentColor.opacity(0.6)]
|
||||||
|
var midGradientColor: Color // blend of the two gradient colors — for richer angular gradients
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll also need a `Color.blendedWith(_:fraction:)` helper that interpolates between two colors using `UIColor.getRed`.
|
||||||
|
|
||||||
|
### 2. ShareCardSportBackground.swift — Three-Layer Background System
|
||||||
|
|
||||||
|
Replace the current flat gradient + scattered icons with a `ZStack` of three layers:
|
||||||
|
|
||||||
|
**Layer 1 — Angular gradient:** Use `AngularGradient` (not linear) with the theme's two gradient colors plus the derived midGradientColor. Overlay a semi-transparent `LinearGradient` to soften it.
|
||||||
|
|
||||||
|
**Layer 2 — Sport geometric pattern:** Use `Shape` protocol to draw repeating geometric paths:
|
||||||
|
- **MLB:** Diamond/rhombus lattice (rotated squares in a grid, ~60pt spacing, staggered rows)
|
||||||
|
- **NBA/WNBA:** Hexagonal honeycomb (6-sided shapes, ~40pt size)
|
||||||
|
- **NHL:** Angled slash lines (~36pt spacing, rotated ~35 degrees via `CGAffineTransform`)
|
||||||
|
- **NFL/MLS/NWSL/default:** Diagonal stripes (~48pt spacing, 45 degrees)
|
||||||
|
- All rendered at 0.06-0.08 opacity using `theme.textColor`
|
||||||
|
|
||||||
|
**Layer 3 — Edge vignette:** `RadialGradient` from `.clear` center to `.black.opacity(0.4)` at edges. Start radius ~200, end radius ~900.
|
||||||
|
|
||||||
|
**Important:** The `ShareCardBackground` wrapper view should now always use `ShareCardSportBackground` (even for no sports — just use the default stripe pattern). This means empty sports set should still get the angular gradient + stripes + vignette.
|
||||||
|
|
||||||
|
### 3. ShareCardComponents.swift — Premium Shared Components
|
||||||
|
|
||||||
|
**ShareCardHeader:**
|
||||||
|
- Sport icon inside a filled `RoundedRectangle(cornerRadius: 18)` (not a Circle), with accent color at 0.25 opacity, 80x80pt, subtle shadow
|
||||||
|
- Title: `.system(size: 44, weight: .heavy, design: .default)` — NOT rounded
|
||||||
|
- Thin accent-colored `Rectangle` below (200pt wide, 2pt tall)
|
||||||
|
|
||||||
|
**ShareCardFooter:**
|
||||||
|
- Thin horizontal rule above (textColor at 0.15 opacity)
|
||||||
|
- "SPORTSTIME" in `.system(size: 18, weight: .bold)` with `.tracking(6)` in accent color
|
||||||
|
- "Plan your stadium adventure" in `.system(size: 16, weight: .light)`
|
||||||
|
- Compact — no sport icon in the footer
|
||||||
|
|
||||||
|
**ShareStatsRow — "Broadcast Stats Panel":**
|
||||||
|
- `HStack(spacing: 0)` with each stat taking `maxWidth: .infinity`
|
||||||
|
- Thin vertical `Rectangle` dividers between stats (accent color at 0.4 opacity, 1pt wide, 60pt tall)
|
||||||
|
- Stat value: `.system(size: 48, weight: .black, design: .rounded)` in accent color
|
||||||
|
- Stat label: `.system(size: 16, weight: .medium)` ALL CAPS with `.tracking(2)` in secondaryTextColor
|
||||||
|
- Whole row in a glass panel: `surfaceColor` fill, `borderColor` stroke, cornerRadius 20
|
||||||
|
|
||||||
|
**ShareProgressRing:**
|
||||||
|
- `lineWidth: 32` (thicker than before)
|
||||||
|
- Track ring: `surfaceColor` (not just accent at low opacity)
|
||||||
|
- Glow behind progress arc: same arc shape but with `glowColor`, lineWidth + 12, `.blur(radius: 10)`
|
||||||
|
- Tick marks at 25/50/75%: short radial lines on the track ring (textColor at 0.2 opacity)
|
||||||
|
- Center number: `.system(size: 108, weight: .black, design: .rounded)`
|
||||||
|
- "of XX": `.system(size: 28, weight: .light)`
|
||||||
|
|
||||||
|
**ShareMapSnapshotGenerator (UIKit CoreGraphics drawing):**
|
||||||
|
- Stadium dots: 20pt diameter with 2pt white ring border. Visited ones get a small white checkmark drawn on top.
|
||||||
|
- Route line: 6pt width (was 4pt)
|
||||||
|
- City labels: Draw text size dynamically using `NSString.size(withAttributes:)`, then draw a rounded-rect background sized to fit. Use "START" and "END" labels for first/last stops. Use `.heavy` weight font.
|
||||||
|
|
||||||
|
### 4. AchievementCardGenerator.swift — Badge, Confetti, All 4 Card Types
|
||||||
|
|
||||||
|
**AchievementBadge:**
|
||||||
|
- Outer gradient ring: `LinearGradient` of iconColor → iconColor.opacity(0.5), stroke width = size * 0.025
|
||||||
|
- Inner fill: `RadialGradient` of iconColor.opacity(0.3) → iconColor.opacity(0.1)
|
||||||
|
- Icon: size * 0.45 (slightly larger than current 0.4)
|
||||||
|
- For earned achievements: thin gold (#FFD700) outer ring at full size
|
||||||
|
- Subtle drop shadow on the whole badge
|
||||||
|
- **New parameter:** `isEarned: Bool` (the old badge only took `definition` and `size`)
|
||||||
|
|
||||||
|
**ConfettiBurst — "Pyrotechnics":**
|
||||||
|
- 36 particles (was 24)
|
||||||
|
- 4 shape types: Circle, 6-pointed star (custom `StarShape`), Diamond (rotated rect), thin streamer Rectangle
|
||||||
|
- Two burst rings: inner (150-350pt from center) and outer (380-550pt, at 0.6 opacity)
|
||||||
|
- `goldHeavy: Bool` parameter — when true, uses gold palette (#FFD700, #FFC107, #FFE082, #B8860B, #FF6B35)
|
||||||
|
- **Use deterministic seeded random** (hash-based, not `CGFloat.random`) so the layout is consistent across renders. Formula: `abs(seed &* 2654435761) % range`
|
||||||
|
|
||||||
|
**Spotlight Card:**
|
||||||
|
- Blurred glow circle (glowColor, 420pt, blur 40) behind the badge
|
||||||
|
- Decorative flanking lines: two 100pt accent-colored rectangles with a small circle between them
|
||||||
|
- Achievement name: `.system(size: 52, weight: .heavy)` (NOT rounded)
|
||||||
|
- Description: `.system(size: 26, weight: .light)`
|
||||||
|
- Earned date in a pill: Capsule with surfaceColor fill + borderColor stroke
|
||||||
|
|
||||||
|
**Milestone Card:**
|
||||||
|
- "MILESTONE" label: `.system(size: 26, weight: .black)`, tracking 6, in gold (#FFD700), with text shadow
|
||||||
|
- Double ring around badge: gold outer (5pt, LinearGradient gold→darkGold), accent inner (3pt)
|
||||||
|
- Badge at 460pt
|
||||||
|
- Name text has a `RoundedRectangle` background with gold gradient (0.15→0.05 opacity)
|
||||||
|
- Confetti with `goldHeavy: true`
|
||||||
|
- Earned date in gold pill (gold text, gold-tinted capsule)
|
||||||
|
|
||||||
|
**Collection Card:**
|
||||||
|
- Year in huge type: `.system(size: 72, weight: .black, design: .rounded)` in accent color
|
||||||
|
- Sport label below in ALL CAPS tracked secondary text
|
||||||
|
- 3-column `LazyVGrid` with each cell being a glass panel (surfaceColor + borderColor) containing badge (180pt) + name (2 lines)
|
||||||
|
- Bottom: count in accent-tinted pill ("12 UNLOCKED" in tracked bold text)
|
||||||
|
|
||||||
|
**Context Card:**
|
||||||
|
- Glass header bar (full-width RoundedRectangle, surfaceColor fill): badge (130pt) + name + "UNLOCKED" tracked text
|
||||||
|
- Dotted connection line: 5 small circles vertically (accent at 0.3 opacity)
|
||||||
|
- Map snapshot with shadow
|
||||||
|
- Trip name in a glass banner at bottom ("Unlocked during" light + trip name bold)
|
||||||
|
|
||||||
|
### 5. ProgressCardGenerator.swift — Stadium Progress Card
|
||||||
|
|
||||||
|
- Custom header (not ShareCardHeader): sport icon in rounded rect + "STADIUM QUEST" in `.system(size: 36, weight: .heavy)` with `.tracking(3)`
|
||||||
|
- If 100% complete: "COMPLETED" badge in gold capsule at top
|
||||||
|
- Progress ring (redesigned version)
|
||||||
|
- Below ring: percentage number in `.system(size: 56, weight: .black, design: .rounded)` + "% COMPLETE" tracked
|
||||||
|
- Broadcast stats panel: visited / remain / trips
|
||||||
|
- Map with themed border; gold border if complete
|
||||||
|
- If 100% complete: swap accent color to gold for the progress ring
|
||||||
|
|
||||||
|
### 6. TripCardGenerator.swift — Trip Summary Card
|
||||||
|
|
||||||
|
- Title: "MY [SPORT] ROAD TRIP" (ALL CAPS) via ShareCardHeader
|
||||||
|
- Map: larger frame (maxHeight: 680), with border and shadow
|
||||||
|
- Date range: in a glass pill (Capsule with surfaceColor + borderColor)
|
||||||
|
- Broadcast stats panel: miles / games / cities
|
||||||
|
- City journey trail: horizontal `ScrollView` with numbered city badges
|
||||||
|
- Each city: `HStack` of numbered circle (accent fill, white number) + 3-letter city abbreviation, inside a glass rounded rect
|
||||||
|
- Arrow connectors between cities (SF Symbol `arrow.right`)
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
- All views use `ShareCardBackground(theme:sports:)` as background
|
||||||
|
- All views are framed to `ShareCardDimensions.cardSize` (1080x1920)
|
||||||
|
- All card views are `private struct` — only the `*Content` structs are public
|
||||||
|
- Rendering happens via `ImageRenderer(content:)` with `scale = 3.0`
|
||||||
|
- Keep all existing public API signatures unchanged (the `*Content` structs and their `render(theme:)` methods)
|
||||||
|
- Glass panels = surfaceColor fill + borderColor stroke + corner radius 16-20
|
||||||
|
- `ShareCardSportBackground`, `ShareCardBackground`, `ShareCardHeader`, `ShareCardFooter`, `ShareStatsRow`, `ShareProgressRing`, `ShareMapSnapshotGenerator` are NOT private — they're used across files
|
||||||
|
- Achievement badge, confetti, star shape are private to AchievementCardGenerator
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, build with:
|
||||||
|
```bash
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build must succeed with zero errors.
|
||||||
109
docs/STORYBOARD_PACK.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# SportsTime Ad Storyboard Pack (10 Videos)
|
||||||
|
|
||||||
|
**Format:** 9:16 vertical (1080x1920), 30fps
|
||||||
|
**Duration:** 12-16 seconds each
|
||||||
|
**Date:** 2026-02-07
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
- App populated with 2026 season data across all 7 sports
|
||||||
|
- Pro subscription unlocked for recording
|
||||||
|
- 3+ saved trips exist (East Coast 5-city, Cross-Country, Regional 3-city)
|
||||||
|
- 1+ group poll with 3 trip options and 4+ votes
|
||||||
|
- 5-10 stadium visits logged across MLB/NBA for progress footage
|
||||||
|
- 2-3 achievements earned (First Stadium, 5 Stadiums, one division)
|
||||||
|
- Dark mode unless specified; animated background ON
|
||||||
|
- All recordings are native screen recordings, scaled to 1080x1920 in post
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECTION 0: FEATURE SHOWCASE DEFINITIONS
|
||||||
|
|
||||||
|
| Feature ID | Feature Name | User Outcome | Exact In-App Proof Moment | Why It Matters In Ad | Required Clip IDs | Overlay Copy (max 7 words) | Priority | Evidence File Path(s) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| F01 | Trip Planning Wizard | Plan a multi-stop trip in minutes | Home > "Start Planning" > wizard steps > "Plan Trip" > results | Core value prop | CLIP-002,003,004,005,006,031 | "Plan your dream sports trip" | P1 | `Features/Trip/Views/Wizard/TripWizardView.swift` |
|
||||||
|
| F02 | Schedule Browsing | Browse games across 7 sports | Schedule tab > sport chips > scroll game list | Shows data depth | CLIP-015,016,017 | "Every game. Every sport." | P1 | `Features/Schedule/Views/ScheduleListView.swift` |
|
||||||
|
| F03 | Route Map | See optimized route on map | Trip Detail > hero map with golden polylines + markers | Visual hero shot | CLIP-009 | "Your route, optimized" | P1 | `Features/Trip/Views/TripDetailView.swift` |
|
||||||
|
| F04 | Day-by-Day Itinerary | See full schedule with games + travel | Trip Detail > scroll day cards with games, travel | Tangible trip output | CLIP-011,012 | "Every day planned" | P1 | `Features/Trip/Views/TripDetailView.swift` |
|
||||||
|
| F05 | Multiple Trip Options | Choose from ranked itineraries | After planning > options screen with sort/filter | Algorithm intelligence | CLIP-007 | "Pick your perfect route" | P2 | `Features/Trip/Views/TripOptionsView.swift` |
|
||||||
|
| F06 | Save Trip | Save trip with one tap | Trip Detail > tap heart > spring animation | Low-friction commitment | CLIP-013 | "Save it. Go later." | P1 | `Features/Trip/Views/TripDetailView.swift` |
|
||||||
|
| F07 | Stadium Progress | Track visited stadiums per league | Progress tab > rings + map + lists | Gamification, retention | CLIP-018,019,020 | "Track every stadium" | P2 | `Features/Progress/Views/ProgressTabView.swift` |
|
||||||
|
| F08 | Achievements | Earn badges for milestones | Achievements grid > gold rings > detail sheet | Reward psychology | CLIP-021,022 | "Earn badges. Complete leagues." | P2 | `Features/Progress/Views/AchievementsListView.swift` |
|
||||||
|
| F09 | Group Polls | Let friends vote on trip options | My Trips > create poll > share code > vote > results | Social/viral hook | CLIP-023,024,025,026 | "Plan trips with friends" | P2 | `Features/Polls/Views/PollDetailView.swift` |
|
||||||
|
| F10 | PDF Export | Export magazine-style PDF | Trip Detail > export > progress overlay > complete | Premium utility | CLIP-027 | "Export your playbook" | P3 | `Export/PDFGenerator.swift` |
|
||||||
|
| F11 | Share Cards | Share progress on social media | Share button > preview card > share sheet | Viral/social proof | CLIP-028 | "Share your adventure" | P3 | `Export/Sharing/ShareCardComponents.swift` |
|
||||||
|
| F12 | Multi-Sport Coverage | 7 sports, 150+ stadiums | Wizard sport step > 7 checkboxes / Schedule chips | Breadth differentiator | CLIP-005,015,016 | "7 sports. 150+ stadiums." | P1 | `Core/Models/Domain/Sport.swift` |
|
||||||
|
| F13 | Trip Score | Quality rating for trip plan | Trip Detail > score card with letter grade | Intelligence signal | CLIP-014 | "AI-scored trip quality" | P3 | `Planning/Scoring/TripScorer.swift` |
|
||||||
|
| F14 | Route Stats | Cities, games, miles, hours at a glance | Trip Detail > stats row with icon pills | Quick comprehension | CLIP-010 | "See the full picture" | P2 | `Features/Trip/Views/TripDetailView.swift` |
|
||||||
|
| F15 | EV Charging | EV charging stops along route | Trip Detail > travel segment > expand chargers | Modern differentiator | CLIP-034 | "EV-friendly road trips" | P3 | `Core/Models/Domain/TravelSegment.swift` |
|
||||||
|
| F16 | Planning Modes | Date Range, Game First, Follow Team, Team First | Wizard > select planning mode | Flexibility | CLIP-003,029,030 | "Plan it your way" | P2 | `Features/Trip/Views/Wizard/TripWizardView.swift` |
|
||||||
|
| F17 | Featured Trips | Browse curated suggestions by region | Home > featured trips carousel > swipe | Discovery/inspiration | CLIP-036 | "Get inspired" | P3 | `Features/Home/Views/HomeView.swift` |
|
||||||
|
| F18 | Travel Segments | Driving distance/time between cities | Trip Detail > travel card (CityA > CityB + stats) | Practical logistics | CLIP-035 | "Know every mile" | P3 | `Features/Trip/Views/TripDetailView.swift` |
|
||||||
|
|
||||||
|
### Feature Coverage Rules
|
||||||
|
|
||||||
|
**Must appear in 3+ videos (P1):**
|
||||||
|
- F01 (Trip Planning Wizard): V01, V02, V03, V04, V08, V10
|
||||||
|
- F03 (Route Map): all 10 videos
|
||||||
|
- F04 (Itinerary): V01, V04, V05, V06, V07, V08, V09
|
||||||
|
- F06 (Save Trip): V01, V02, V03, V04, V05, V06, V08, V10
|
||||||
|
- F12 (Multi-Sport): V01, V02, V03, V04, V08, V09, V10
|
||||||
|
|
||||||
|
**Can appear in 1-2 videos (P2/P3):**
|
||||||
|
- F05 (Options): V02, V04
|
||||||
|
- F07 (Progress): V01, V05
|
||||||
|
- F08 (Achievements): V01, V05
|
||||||
|
- F09 (Group Polls): V06
|
||||||
|
- F10 (PDF Export): V07
|
||||||
|
- F11 (Share Cards): V05, V07
|
||||||
|
- F13 (Trip Score): V03, V09
|
||||||
|
- F15 (EV Charging): V03, V09
|
||||||
|
- F16 (Planning Modes): V02, V10
|
||||||
|
|
||||||
|
**Feature pairings (same video):**
|
||||||
|
- F03 + F04 + F14: "trip output" trio (V01, V07, V09)
|
||||||
|
- F01 + F05 + F06: "planning flow" trio (V02, V04)
|
||||||
|
- F07 + F08 + F11: "gamification" trio (V05)
|
||||||
|
- F02 + F12: "breadth" pair (V04, V08)
|
||||||
|
- F03 + F18 + F15: "route detail" trio (V03, V09)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECTION 1: MASTER REUSABLE CLIP LIBRARY
|
||||||
|
|
||||||
|
| Clip ID | Screen | Exact Action to Record | Start State | End State | Raw Length | Capture Notes | Reused In |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| CLIP-001 | Home tab | App on home screen; animated background plays, hero card visible | Home tab selected, top of page | Hero card + top of featured section | 4s | No notifications. Let animated icons float 2 full cycles. Finger off screen. | V01,V02,V10 |
|
||||||
|
| CLIP-002 | Home tab | Tap orange "Start Planning" button; wizard sheet rises | Hero card centered | Wizard modal sheet rising | 3s | Tap center of button. Wait for full spring animation + sheet appear. | V01,V02,V03,V04,V08,V10 |
|
||||||
|
| CLIP-003 | Trip Wizard | Select "Date Range" planning mode radio button | Wizard open, no mode selected | "Date Range" highlighted blue | 2s | All modes deselected at start. Single clean tap. | V02,V10 |
|
||||||
|
| CLIP-004 | Trip Wizard | Pick start date then end date using date pickers | Date step visible, no dates | Both dates selected, validation green | 4s | Dates 10+ days apart. Use dates with known games. Smooth date picker scrolls. | V02,V10 |
|
||||||
|
| CLIP-005 | Trip Wizard | Tap 3 sport chips: MLB > NBA > NHL, each highlights | Sports step, none selected | 3 sports selected, colored chips | 3s | Pause 0.3s between taps. Tap center of each chip. | V01,V02,V03,V04,V08,V10 |
|
||||||
|
| CLIP-006 | Trip Wizard | Tap "Plan Trip" button; loading spinner appears | Review step, all fields valid | Spinner active, "Generating..." | 3s | All required fields filled. Get button press + spinner start. | V02,V03 |
|
||||||
|
| CLIP-007 | Trip Options | Options screen appears; 3+ cards with staggered entrance | Loading completes | 3+ option cards with stats visible | 3s | Ensure planning produces 3+ options. Capture staggered entrance. | V02,V04 |
|
||||||
|
| CLIP-009 | Trip Detail | Map hero with golden route polylines and stop markers; zoom settles | Trip detail loaded | Map rendered with all routes + markers | 5s | Trip with 4-6 stops. Wait for polylines to render. Let map settle. **Most reused clip.** | All 10 |
|
||||||
|
| CLIP-010 | Trip Detail | Stats row: calendar+days, mappin+cities, court+games, road+miles, car+hours | Header area visible | Stats row centered and readable | 3s | Trip with 5+ cities, 8+ games, 1000+ mi. | V03,V07,V09 |
|
||||||
|
| CLIP-011 | Trip Detail | Scroll itinerary: Day 1 header, 2-3 game cards with sport color bars, matchups | Itinerary top | 2-3 game cards readable | 5s | Scroll slowly, constant speed. Different sport games for color variety. | V01,V04,V05,V06,V07,V08,V09 |
|
||||||
|
| CLIP-012 | Trip Detail | Continue scrolling: travel segment + Day 2-3 games | After Day 1 | Travel card + Day 2 header + games | 5s | Same session as CLIP-011. Maintain scroll speed. | V07,V09 |
|
||||||
|
| CLIP-013 | Trip Detail | Tap heart/save button; spring animation, heart fills red | Heart visible (outline) | Heart filled red, spring complete | 2s | Heart button clearly visible. Clean tap. Full spring animation (0.5s). | V01,V02,V03,V04,V05,V06,V08,V10 |
|
||||||
|
| CLIP-014 | Trip Detail | Trip score card: large letter grade + 4 sub-scores | Scrolled to score section | Score card fully visible | 3s | Trip that scores A or A+. All 4 sub-scores readable. | V03,V09 |
|
||||||
|
| CLIP-015 | Schedule tab | Tab loads: sport chips + initial game list with 2-3 games | Schedule tab selected | Chips + first game rows loaded | 3s | Games loaded. MLB pre-selected. | V04,V08 |
|
||||||
|
| CLIP-016 | Schedule tab | Tap through 3 sport filters: MLB > NBA > NHL; list updates each | MLB selected | NHL selected, its games visible | 5s | Pause 0.8s per sport for list update. Team color dots change. | V04,V08 |
|
||||||
|
| CLIP-017 | Schedule tab | Scroll game list showing 5-6 rows with badges, times, stadiums | One sport's games loaded | 5-6 rows scrolled | 4s | Scroll at reading speed. "TODAY" badge on one game. | V04,V08 |
|
||||||
|
| CLIP-018 | Progress tab | Tab loads: league buttons with progress rings filling | Progress tab selected | Rings animated, sport buttons visible | 4s | 5-10 stadiums visited for ring progress. Capture ring fill on load. | V01,V05 |
|
||||||
|
| CLIP-019 | Progress tab | Stadium map: green visited dots + gray unvisited dots | Map section visible | US map with colored dots | 3s | Visits in different regions for spread. Let map settle. | V05 |
|
||||||
|
| CLIP-020 | Progress tab | Scroll visited stadium chips: green checkmarks, names, cities | Visited section header | 3-4 chips scrolled | 3s | 4+ stadiums visited. Scroll left slowly. Checkmarks visible. | V05 |
|
||||||
|
| CLIP-021 | Achievements | Grid: 4-6 badges, mix of earned (gold ring) and locked | Achievements loaded | Grid with gold + locked badges | 4s | 2-3 achievements earned. Gold glow visible. | V01,V05 |
|
||||||
|
| CLIP-022 | Achievements | Tap earned badge; detail sheet slides up with badge + date | Grid visible | Sheet open, "Earned on [date]" visible | 3s | Tap a visually appealing badge. Get sheet animation. | V05 |
|
||||||
|
| CLIP-023 | My Trips tab | 3+ saved trip cards + "Group Polls" section header | Tab selected | Cards + poll section visible | 3s | 3+ saved trips. Cards show varied cities. Poll header in frame. | V06 |
|
||||||
|
| CLIP-024 | Poll creation | Select 2-3 trips; checkmark on each selection | Poll sheet open | 2-3 trips checked | 3s | Pause between taps. Get checkmark animation. | V06 |
|
||||||
|
| CLIP-025 | Poll detail | Share code card: large "AB3K7X" in orange monospace | Poll detail loaded | Code clearly readable | 3s | Use existing poll. Orange text readable. | V06 |
|
||||||
|
| CLIP-026 | Poll detail | Voting results: bars fill, rank medals appear | Results section visible | Bars filled, rankings visible | 4s | 3+ votes. Capture bar animation. Get trophy/medal icons. | V06 |
|
||||||
|
| CLIP-027 | Trip Detail | Tap export; progress overlay: ring fills, step text changes | Toolbar visible | Overlay at ~50-100% | 4s | Must be Pro. Capture ring fill + "Preparing > Rendering > Complete" text. | V07 |
|
||||||
|
| CLIP-028 | Share preview | Preview card displayed with visual, "Share" button below | Share sheet open | Card preview visible | 3s | Trigger from progress share. Get rendered card. | V05,V07 |
|
||||||
|
| CLIP-029 | Trip Wizard | Select "Game First" planning mode | Wizard open, no mode selected | "Game First" highlighted | 2s | Record separately from CLIP-003. | V10 |
|
||||||
|
| CLIP-030 | Trip Wizard | Game picker: type team name, select 2 games | Game picker, search empty | 2 games checked | 4s | Type "Yankees" or similar. Tap 2 games. | V10 |
|
||||||
|
| CLIP-031 | Trip Wizard | Select regions: tap East + Central | Regions step, none selected | East + Central highlighted | 2s | Clean taps on region chips. | V10 |
|
||||||
|
| CLIP-034 | Trip Detail | EV charger section expands: green dots + charger names + type badges | Travel segment, EV collapsed | 2-3 chargers visible | 3s | Trip with EV enabled. Segment with 2+ chargers. | V03,V09 |
|
||||||
|
| CLIP-035 | Trip Detail | Travel segment card: car icon, "Boston > New York", "215 mi - 3h 45m" | Scrolled to travel segment | Card centered and readable | 3s | Recognizable cities. Card centered. | V03,V09 |
|
||||||
|
| CLIP-037 | Trip Detail | Sport badges row: colored capsules ("MLB", "NBA", "NHL") | Header area | Badge capsules visible | 2s | Multi-sport trip (3+ sports). Color variety. | V08,V09 |
|
||||||
|
|
||||||
|
**Total unique clips: 33**
|
||||||
350
docs/STORYBOARD_PRODUCTION.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# SECTION 3: REUSE MATRIX
|
||||||
|
|
||||||
|
Rows = Clip IDs, Columns = Videos. Cell = timecode range where used. Empty = not used.
|
||||||
|
|
||||||
|
| Clip ID | V01 | V02 | V03 | V04 | V05 | V06 | V07 | V08 | V09 | V10 | Total Uses |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| CLIP-001 | 0.0-1.5 | 0.0-1.0 | — | — | — | — | — | — | — | 0.0-1.0 | 3 |
|
||||||
|
| CLIP-002 | 1.5-2.5 | 1.0-2.0 | 0.0-1.0 | 5.0-6.0 | — | — | — | 6.0-7.0 | — | 1.0-2.0 | 6 |
|
||||||
|
| CLIP-003 | — | 2.0-3.0 | — | — | — | — | — | — | — | 2.0-3.0 | 2 |
|
||||||
|
| CLIP-004 | — | 3.0-4.5 | — | — | — | — | — | — | — | 6.0-7.0 | 2 |
|
||||||
|
| CLIP-005 | 2.5-4.0 | 4.5-5.5 | 1.0-2.0 | 6.0-7.0 | — | — | — | 7.0-8.0 | — | 8.5-9.5 | 6 |
|
||||||
|
| CLIP-006 | — | 5.5-7.0 | 2.0-3.0 | — | — | — | — | — | — | — | 2 |
|
||||||
|
| CLIP-007 | — | 7.0-8.5 | — | 7.0-8.0 | — | — | — | — | — | — | 2 |
|
||||||
|
| CLIP-009 | 4.0-6.0 | 8.5-10.5 | 3.0-5.5 | 8.0-10.0 | 10.5-12.0 | 7.0-9.0 | 0.0-1.5 | 8.0-10.0 | 0.0-2.0 | 9.5-11.0 | **10** |
|
||||||
|
| CLIP-010 | — | — | 5.5-7.0 | — | — | — | 9.5-11.0 | — | 2.0-3.5 | — | 3 |
|
||||||
|
| CLIP-011 | 6.0-8.0 | — | — | 10.0-12.0 | — | 9.0-10.5 | 1.5-3.5 | 11.0-13.0 | 4.5-7.5 | — | **7** (tied 2nd) |
|
||||||
|
| CLIP-012 | — | — | — | — | — | — | 3.5-5.5 | — | 7.5-10.0 | — | 2 |
|
||||||
|
| CLIP-013 | 11.0-12.0 | 10.5-11.5 | 11.5-12.5 | 12.0-12.5 | 12.0-13.0 | 10.5-11.5 | — | 13.0-13.5 | — | 11.0-12.0 | **8** |
|
||||||
|
| CLIP-014 | — | — | 10.0-11.5 | — | — | — | — | — | 13.0-14.5 | — | 2 |
|
||||||
|
| CLIP-015 | — | — | — | 0.0-1.5 | — | — | — | 0.0-1.5 | — | — | 2 |
|
||||||
|
| CLIP-016 | — | — | — | 1.5-3.5 | — | — | — | 1.5-4.5 | — | — | 2 |
|
||||||
|
| CLIP-017 | — | — | — | 3.5-5.0 | — | — | — | 4.5-6.0 | — | — | 2 |
|
||||||
|
| CLIP-018 | 8.0-9.5 | — | — | — | 0.0-2.0 | — | — | — | — | — | 2 |
|
||||||
|
| CLIP-019 | — | — | — | — | 2.0-4.0 | — | — | — | — | — | 1 |
|
||||||
|
| CLIP-020 | — | — | — | — | 4.0-5.5 | — | — | — | — | — | 1 |
|
||||||
|
| CLIP-021 | 9.5-11.0 | — | — | — | 5.5-7.5 | — | — | — | — | — | 2 |
|
||||||
|
| CLIP-022 | — | — | — | — | 7.5-9.0 | — | — | — | — | — | 1 |
|
||||||
|
| CLIP-023 | — | — | — | — | — | 0.0-1.5 | — | — | — | — | 1 |
|
||||||
|
| CLIP-024 | — | — | — | — | — | 1.5-3.5 | — | — | — | — | 1 |
|
||||||
|
| CLIP-025 | — | — | — | — | — | 3.5-5.0 | — | — | — | — | 1 |
|
||||||
|
| CLIP-026 | — | — | — | — | — | 5.0-7.0 | — | — | — | — | 1 |
|
||||||
|
| CLIP-027 | — | — | — | — | — | — | 5.5-8.0 | — | — | — | 1 |
|
||||||
|
| CLIP-028 | — | — | — | — | 9.0-10.5 | — | 8.0-9.5 | — | — | — | 2 |
|
||||||
|
| CLIP-029 | — | — | — | — | — | — | — | — | — | 3.0-4.5 | 1 |
|
||||||
|
| CLIP-030 | — | — | — | — | — | — | — | — | — | 4.5-6.0 | 1 |
|
||||||
|
| CLIP-031 | — | — | — | — | — | — | — | — | — | 7.0-8.5 | 1 |
|
||||||
|
| CLIP-034 | — | — | 8.5-10.0 | — | — | — | — | — | 11.5-13.0 | — | 2 |
|
||||||
|
| CLIP-035 | — | — | 7.0-8.5 | — | — | — | — | — | 10.0-11.5 | — | 2 |
|
||||||
|
| CLIP-037 | — | — | — | — | — | — | — | 10.0-11.0 | 3.5-4.5 | — | 2 |
|
||||||
|
|
||||||
|
### Summary Stats
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Total unique clips** | 33 |
|
||||||
|
| **Clips used in 2+ videos** | 22 (67%) |
|
||||||
|
| **Clips used in 1 video only** | 11 (33%) |
|
||||||
|
| **Total shot placements** | 83 |
|
||||||
|
| **Reused placements** (from multi-use clips) | 72 (87%) |
|
||||||
|
| **Total video duration** | 143s |
|
||||||
|
| **Seconds from shared clips** | ~124s (87%) |
|
||||||
|
| **Seconds from single-use clips** | ~19s (13%) |
|
||||||
|
| **Footage reuse rate** | **87%** (target was 70%) |
|
||||||
|
| **Most reused clip** | CLIP-009 (map hero) — all 10 videos |
|
||||||
|
| **2nd most reused** | CLIP-013 (save heart) — 8 videos |
|
||||||
|
| **3rd most reused** | CLIP-011 (itinerary scroll) — 7 videos |
|
||||||
|
|
||||||
|
### Net New Clips Per Video
|
||||||
|
|
||||||
|
| Video | Total Clips Used | Net New (single-use) | % Shared |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V01 | 8 | 0 | 100% |
|
||||||
|
| V02 | 9 | 0 | 100% |
|
||||||
|
| V03 | 9 | 0 | 100% |
|
||||||
|
| V04 | 9 | 0 | 100% |
|
||||||
|
| V05 | 8 | 3 (019,020,022) | 63% |
|
||||||
|
| V06 | 7 | 4 (023,024,025,026) | 43% |
|
||||||
|
| V07 | 6 | 1 (027) | 83% |
|
||||||
|
| V08 | 9 | 0 | 100% |
|
||||||
|
| V09 | 8 | 0 | 100% |
|
||||||
|
| V10 | 10 | 3 (029,030,031) | 70% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SECTION 4: CAPTURE DAY SHOT LIST
|
||||||
|
|
||||||
|
Record in app-flow order to minimize navigation. Grouped by screen to batch efficiently.
|
||||||
|
|
||||||
|
## Group A: Home Tab (3 clips)
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | CLIP-001 | 1. Open app, ensure Home tab selected. 2. Scroll to top if needed. 3. Wait for animated background to cycle. 4. Record 4s with finger off screen. | Normal | 2 | Don't touch screen. No notifications. Ensure hero card text fully visible. |
|
||||||
|
| 2 | CLIP-002 | 1. Position so hero card "Start Planning" button is centered. 2. Tap button cleanly in center. 3. Hold recording through sheet rise animation. | Normal | 3 | Don't tap edge of button. Wait for full spring animation. Don't dismiss the sheet yet. |
|
||||||
|
| 3 | — | Dismiss wizard sheet (prep for next group). | — | — | — |
|
||||||
|
|
||||||
|
## Group B: Trip Wizard - Date Range Mode (5 clips)
|
||||||
|
|
||||||
|
Start fresh: Tap "Start Planning" from home, then record each step.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 4 | CLIP-003 | 1. Wizard open, no mode selected. 2. Tap "Date Range" radio button. 3. Hold 0.5s showing blue highlight. | Normal | 2 | Ensure all modes deselected at start. Single clean tap. |
|
||||||
|
| 5 | CLIP-004 | 1. Scroll to Dates step. 2. Tap start date picker, scroll to target month, tap day. 3. Tap end date picker, scroll, tap day. 4. Dates should be 10-14 days apart. | Slow | 3 | Smooth date picker scrolling. Don't overshoot months. Pick dates with games (e.g., late June for MLB). |
|
||||||
|
| 6 | CLIP-005 | 1. Scroll to Sports step. 2. Tap MLB chip (pause 0.3s). 3. Tap NBA chip (pause 0.3s). 4. Tap NHL chip. | Normal | 2 | Even spacing between taps. Tap center of chips. All 3 should show selected state. |
|
||||||
|
| 7 | CLIP-031 | 1. Scroll to Regions step. 2. Tap "East" chip. 3. Tap "Central" chip. | Normal | 2 | Clean taps. Both chips highlighted after. |
|
||||||
|
| 8 | CLIP-006 | 1. Scroll to Review step. 2. Verify all fields valid (no red text). 3. Tap orange "Plan Trip" button. 4. Hold through loading spinner. | Normal | 2 | All fields must be valid. Get button press animation + spinner. Don't stop recording until spinner visible. |
|
||||||
|
|
||||||
|
## Group C: Trip Wizard - Game First Mode (2 clips)
|
||||||
|
|
||||||
|
Dismiss and re-open wizard for fresh state.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 9 | CLIP-029 | 1. Wizard open, no mode selected. 2. Tap "Game First" radio button. | Normal | 2 | Must be different recording from CLIP-003. |
|
||||||
|
| 10 | CLIP-030 | 1. Game picker visible. 2. Tap search field, type "Yankees" (or similar popular team). 3. Wait for results. 4. Tap first game (checkmark appears). 5. Tap second game. | Slow | 3 | Type slowly for readability. Pause after each selection. Get checkmark animations. |
|
||||||
|
|
||||||
|
## Group D: Trip Options (1 clip)
|
||||||
|
|
||||||
|
Let the planning engine complete from Group B's recording, or trigger a fresh plan.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 11 | CLIP-007 | 1. Planning completes. 2. Trip Options screen loads. 3. Record the staggered card entrance (3+ options). | Normal | 2 | Ensure planning produces 3+ options (use broad date range + multiple sports). Capture entrance animation. |
|
||||||
|
|
||||||
|
## Group E: Trip Detail - Map & Header (5 clips)
|
||||||
|
|
||||||
|
Select a trip with 4-6 stops, multiple sports, 1000+ miles, EV charging enabled.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 12 | CLIP-009 | 1. Open trip detail. 2. Let map render fully (golden polylines + all markers). 3. Record 5s of settled map. | Normal | 3 | **Critical clip.** Wait for ALL polylines. Let map zoom settle. No touch. Use trip with 4-6 stops for visual density. |
|
||||||
|
| 13 | CLIP-010 | 1. Scroll to show stats row (just below map). 2. Hold with stats centered and readable. | Normal | 2 | All 5 stat pills visible. Numbers should be impressive (5+ cities, 8+ games). |
|
||||||
|
| 14 | CLIP-037 | 1. Sport badges row visible below header. 2. Hold showing colored capsules. | Normal | 1 | Use multi-sport trip (3+ sports). |
|
||||||
|
| 15 | CLIP-014 | 1. Scroll to trip score card. 2. Hold showing letter grade + 4 sub-scores. | Normal | 2 | Trip must score A or A+. All sub-scores readable. |
|
||||||
|
| 16 | CLIP-013 | 1. Scroll back up so heart button visible. 2. Tap heart. 3. Hold through spring animation + red fill. | Normal | 3 | Heart must start unfilled. Clean single tap. Full spring animation (0.5s). |
|
||||||
|
|
||||||
|
## Group F: Trip Detail - Itinerary Scroll (5 clips)
|
||||||
|
|
||||||
|
Same trip as Group E. Record as one continuous take, then cut in post.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 17 | CLIP-011 | 1. Start at itinerary top. 2. Scroll slowly downward. 3. Pass Day 1 header, then 2-3 game cards with sport color bars. | Slow | 3 | Constant scroll speed. Don't pause. Different sports for color variety. Record as part of continuous scroll. |
|
||||||
|
| 18 | CLIP-012 | 1. Continue scrolling from CLIP-011. 2. Travel segment appears (car icon, city>city). 3. Day 2 header + games. | Slow | (same take) | Maintain same speed. Get travel card clearly visible. |
|
||||||
|
| 19 | CLIP-035 | 1. Pause scroll on a travel segment. 2. Hold with card centered: "Boston > New York, 215 mi, 3h 45m". | Normal | 2 | Recognizable cities. Card fully readable. |
|
||||||
|
| 20 | CLIP-034 | 1. Find travel segment with EV chargers (collapsed). 2. Tap expand chevron. 3. Green dots + charger names + type badges appear. | Normal | 2 | Trip must have EV enabled and segment with 2+ chargers. Get expand animation. |
|
||||||
|
|
||||||
|
## Group G: Trip Detail - Export (1 clip)
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 21 | CLIP-027 | 1. Tap export button in toolbar. 2. Progress overlay appears: ring fills, step text changes ("Preparing..." > "Rendering..." > percentage). 3. Record until ~50-100% or complete. | Normal | 2 | Must be Pro. Capture ring fill animation + changing text. Don't dismiss until good footage captured. |
|
||||||
|
|
||||||
|
## Group H: Schedule Tab (3 clips)
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 22 | CLIP-015 | 1. Tap Schedule tab. 2. Sport chips load + game list populates. 3. Record initial loaded state with MLB pre-selected. | Normal | 2 | Ensure games loaded. Get chips + first 2-3 game rows. |
|
||||||
|
| 23 | CLIP-016 | 1. Starting with MLB selected. 2. Tap NBA chip (pause 0.8s, list updates). 3. Tap NHL chip (pause 0.8s, list updates). 4. Tap MLS chip (pause 0.8s). | Slow | 3 | Pause long enough for list refresh animation. Get team color dots changing. Record one continuous take. |
|
||||||
|
| 24 | CLIP-017 | 1. One sport selected, game list visible. 2. Scroll down through 5-6 game rows. 3. Try to have a "TODAY" badge visible. | Normal | 2 | Reading-speed scroll. Badges, times, stadiums readable. |
|
||||||
|
|
||||||
|
## Group I: Progress Tab (3 clips)
|
||||||
|
|
||||||
|
Ensure 5-10 stadium visits logged beforehand.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 25 | CLIP-018 | 1. Tap Progress tab. 2. League selector loads with progress rings filling. 3. Record the ring fill animation. | Normal | 3 | Must be Pro. Rings need visible fill (not empty, not 100%). Capture the animation on load. |
|
||||||
|
| 26 | CLIP-019 | 1. Scroll to stadium map. 2. Green dots (visited) + gray dots (unvisited) across US. 3. Hold. | Normal | 2 | Visits in different regions. Let map settle. |
|
||||||
|
| 27 | CLIP-020 | 1. Scroll to visited stadiums section. 2. Horizontal scroll through chips (green checkmarks, names). | Normal | 2 | 4+ visited for visual content. Slow left scroll on chips. |
|
||||||
|
|
||||||
|
## Group J: Achievements (2 clips)
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 28 | CLIP-021 | 1. Navigate to Achievements list. 2. Record grid: gold-ringed earned badges + locked badges. | Normal | 2 | 2-3 earned. Gold glow visible. Mix of earned + locked. |
|
||||||
|
| 29 | CLIP-022 | 1. Tap one earned achievement badge. 2. Detail sheet slides up: large badge, description, "Earned on [date]". | Normal | 2 | Tap visually appealing badge. Capture full sheet animation. |
|
||||||
|
|
||||||
|
## Group K: Polls (4 clips)
|
||||||
|
|
||||||
|
Create poll with 3 trip options + 4 votes beforehand.
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 30 | CLIP-023 | 1. Tap My Trips tab. 2. Record showing 3+ saved trip cards + "Group Polls" section. | Normal | 2 | 3+ trips visible. Poll section in frame. |
|
||||||
|
| 31 | CLIP-024 | 1. Tap "+" to create poll. 2. Select 2-3 trips (checkmark per selection). | Normal | 2 | Pause between taps. Get checkmark animation. |
|
||||||
|
| 32 | CLIP-025 | 1. Open existing poll detail. 2. Share code card: "AB3K7X" in large orange text. | Normal | 1 | Code clearly readable at 9:16 scale. |
|
||||||
|
| 33 | CLIP-026 | 1. Scroll to results section. 2. Progress bars fill, rank medals visible. | Normal | 2 | Need 3+ votes for animated bars. Trophy/medal icons visible. Capture fill animation. |
|
||||||
|
|
||||||
|
## Group L: Share (1 clip)
|
||||||
|
|
||||||
|
| # | Clip ID | Recording Steps | Speed | Takes | Mistakes to Avoid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 34 | CLIP-028 | 1. From Progress tab, tap Share button. 2. Share preview card renders. 3. Hold showing card preview + "Share" button. | Normal | 2 | Get a clean rendered card. Alternatively trigger from Trip Detail share. |
|
||||||
|
|
||||||
|
**Total recordings: 34 clips across ~12 screen groups**
|
||||||
|
**Estimated recording time: 45-60 minutes** (with setup, navigation, retakes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SECTION 5: REMOTION HANDOFF NOTES
|
||||||
|
|
||||||
|
## V01: "The Full Play"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `the-full-play` |
|
||||||
|
| **Background** | Dark gradient base (#0D0D0D to #1A1A2E), subtle animated particles |
|
||||||
|
| **Type style** | Bold sans-serif (Inter Black or SF Pro Display Heavy). Hook text: 48px, centered. Feature labels: 24px, positioned above clip. |
|
||||||
|
| **Transitions** | Whip-pan (fast horizontal slide, 4-6 frame duration). Between map/itinerary: zoom-through. |
|
||||||
|
| **Overlays** | Feature name labels fade in 0.2s before clip, persist during clip. Orange (#FF6B35) accent underline on each label. |
|
||||||
|
| **Audio** | Upbeat electronic, 120-130 BPM. Bass drop at 4.0s (map reveal). Bell ding at 12.0s (CTA). |
|
||||||
|
|
||||||
|
## V02: "Zero to Trip"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `zero-to-trip` |
|
||||||
|
| **Background** | Clean dark (#111111), minimal distractions — let app speak |
|
||||||
|
| **Type style** | Monospaced step counter in top-right corner (SF Mono, 16px, white 60% opacity). "Step 1", "Step 2" etc. Hook: casual handwritten feel (36px). |
|
||||||
|
| **Transitions** | Direction-matched swipes: each clip enters from the direction of wizard flow (bottom-up for sheet, left-right for steps). 6-8 frame duration. |
|
||||||
|
| **Overlays** | Step counter persistent. "3 routes found!" text pops in with scale animation at 7.0s. |
|
||||||
|
| **Audio** | Building momentum track. Each step gets a click. Swell builds through steps. Pop SFX at 7.0s. Bass at 8.5s. |
|
||||||
|
|
||||||
|
## V03: "Route Revealed"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `route-revealed` |
|
||||||
|
| **Background** | Deep navy (#0A1628) to suggest nighttime/maps aesthetic |
|
||||||
|
| **Type style** | Elegant serif (Playfair Display or NYT Cheltenham). Hook: 40px, centered, letter-spaced +2px. Feature labels: 20px italic. |
|
||||||
|
| **Transitions** | Slow dissolves (12-15 frame crossfade). Map reveal: slow iris-open or expanding circle. |
|
||||||
|
| **Overlays** | "Optimizing..." text with pulsing opacity during CLIP-006. Minimal text — let visuals breathe. Gold route line motif in lower third. |
|
||||||
|
| **Audio** | Cinematic ambient, slow build. Soft piano or strings. Bass swell at 3.0s (map). No hard beats until score reveal. |
|
||||||
|
|
||||||
|
## V04: "Every Game"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `every-game` |
|
||||||
|
| **Background** | Dark with sports broadcast aesthetic — subtle grid lines, ticker feel |
|
||||||
|
| **Type style** | Condensed sports font (Oswald Bold or ESPN-style). Hook: 52px, caps. "Browse 1000+ games": scrolling ticker at bottom. |
|
||||||
|
| **Transitions** | Horizontal swipes matching sport filter direction. Quick cuts (3-4 frames) between schedule and planning. |
|
||||||
|
| **Overlays** | Sport color bar at top edge that changes color with each sport filter tap (MLB red > NBA orange > NHL blue). "1000+" counter animates up. |
|
||||||
|
| **Audio** | Sports broadcast energy, stadium crowd ambience layer. Whoosh per sport switch. Scoreboard-style ticking for game scroll. |
|
||||||
|
|
||||||
|
## V05: "Stadium Quest"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `stadium-quest` |
|
||||||
|
| **Background** | Dark with gold particle effects (#FFD700 particles floating) |
|
||||||
|
| **Type style** | Achievement-unlock style: bold rounded (Nunito Black). Hook: 44px. Badge labels: pop-in scale animation. Gold color for earned text. |
|
||||||
|
| **Transitions** | Pop/bounce (spring curve, overshoot 1.2x then settle). Scale-in for each new section. |
|
||||||
|
| **Overlays** | Gold ring motif around clip borders when showing achievements. Sparkle particles on earned badges. Progress bar graphic overlaid at bottom during map shot. |
|
||||||
|
| **Audio** | Gamification music — playful, achievement-y (think mobile game victory). Ring fill SFX (ascending tone). Unlock jingle at 7.5s. |
|
||||||
|
|
||||||
|
## V06: "Squad Goals"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `squad-goals` |
|
||||||
|
| **Background** | Dark with gradient purple-to-blue social vibe (#2D1B69 to #1B3A69) |
|
||||||
|
| **Type style** | Rounded friendly (Poppins SemiBold). Hook: 44px. Labels in chat-bubble containers (rounded rect, white bg, dark text). |
|
||||||
|
| **Transitions** | Slide-in from alternating sides (left, right, left) like a conversation. Share code: pop-in with bounce. |
|
||||||
|
| **Overlays** | Chat-bubble-style containers for text labels. Share code gets highlighted glow. Vote bars use gradient fills (gold for winner). Confetti particle burst on "Winning trip". |
|
||||||
|
| **Audio** | Upbeat social/fun track. Message "sent" SFX at 3.5s. Drum roll building at 5.0s. Celebration SFX at 7.0s. |
|
||||||
|
|
||||||
|
## V07: "Your Playbook"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `your-playbook` |
|
||||||
|
| **Background** | Warm dark cream/paper tone (#1C1A17), suggests printed document |
|
||||||
|
| **Type style** | Serif editorial (Georgia or Lora). Hook: 38px, centered, elegant. "Export as PDF" in small caps. |
|
||||||
|
| **Transitions** | Page-turn effect (3D perspective rotation) between itinerary clips. Slide-reveal for export overlay. |
|
||||||
|
| **Overlays** | Subtle paper texture overlay at 5% opacity. Magazine crop-mark motif in corners. PDF icon graphic animates in during export clip. |
|
||||||
|
| **Audio** | Premium/sophisticated — soft jazz or lo-fi. Paper turn SFX. Completion chime at export finish. |
|
||||||
|
|
||||||
|
## V08: "All Sports"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `all-sports` |
|
||||||
|
| **Background** | Dark base, full-screen color-wipe transitions using sport brand colors |
|
||||||
|
| **Type style** | Bold condensed (Bebas Neue or Impact). Hook: 56px, caps, white on sport-color bg. Sport names: 36px in sport's color. |
|
||||||
|
| **Transitions** | Color-wipe: each sport transition wipes screen in that sport's brand color (MLB red wipe > NBA orange wipe > NHL blue wipe). Fast, 4-5 frames. |
|
||||||
|
| **Overlays** | Sport color bar across full top during each sport section. Sport icon (SF Symbol) animates in corner during filter taps. "One trip. Every sport." fades in over badges. |
|
||||||
|
| **Audio** | High-energy, fast tempo (140+ BPM). Stadium horn hit per sport switch. Bass drop at map reveal. |
|
||||||
|
| **Sport brand colors** | MLB: #E31937, NBA: #F58426, NHL: #0038A8, NFL: #013369, MLS: #80B214, WNBA: #BF2F38, NWSL: #0D5C63 |
|
||||||
|
|
||||||
|
## V09: "Day by Day"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `day-by-day` |
|
||||||
|
| **Background** | Clean dark (#111827), calendar/planner aesthetic |
|
||||||
|
| **Type style** | Clean sans-serif (Inter Regular). Hook: 36px serif for contrast. "Day 1" / "Day 2" labels: 20px, monospaced, left-aligned with calendar icon. |
|
||||||
|
| **Transitions** | Continuous vertical scroll simulation — each clip enters from bottom, exits top. No hard cuts in itinerary section. Slow dissolves for non-itinerary clips. |
|
||||||
|
| **Overlays** | Day number labels appear pinned to left edge as itinerary scrolls. Subtle dotted timeline line along left side connecting days. Green accent dot on EV section. |
|
||||||
|
| **Audio** | Calm, organized — ambient electronic or lo-fi beat. Soft "day change" chime at 7.5s. Green ding at 11.5s (EV). |
|
||||||
|
|
||||||
|
## V10: "Your Rules"
|
||||||
|
|
||||||
|
| Attribute | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **Composition name** | `your-rules` |
|
||||||
|
| **Background** | Dark with split-screen capability (#0F0F0F), morph-ready |
|
||||||
|
| **Type style** | Variable weight (Inter Thin to Inter Black). Hook: 42px, weights animate thin>bold. "Pick dates?" / "Pick games?": toggle-switch style labels that flip. |
|
||||||
|
| **Transitions** | Morph/crossfade: each planning mode morphs into the next (shared elements stay, different elements cross-dissolve). 8-10 frame duration. Mode switch uses flip animation. |
|
||||||
|
| **Overlays** | Toggle-switch graphic that flips between mode names. Region selection gets a mini-map outline overlay. "Built your way" text morphs letter-by-letter. |
|
||||||
|
| **Audio** | Empowering/confident track. Toggle "click" SFX at each mode switch. Build through options. Bass drop at map (9.5s). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Remotion Notes
|
||||||
|
|
||||||
|
**Shared across all compositions:**
|
||||||
|
- All clips are `<OffthreadVideo>` components with `startFrom` and `endAt` for trimming
|
||||||
|
- CTA end card is a shared `<CTAEndCard>` component accepting `headlineText` and `ctaStyle` props
|
||||||
|
- Text overlays use `<spring>` for entrance animations (config: damping 12, mass 0.5)
|
||||||
|
- All text should have slight text-shadow for readability over app footage (0 2px 8px rgba(0,0,0,0.8))
|
||||||
|
- Background audio per video: separate audio file, normalized to -14 LUFS
|
||||||
|
- SFX layer: separate track, timed to frame
|
||||||
|
- Export at 30fps, 1080x1920, H.264 for social, ProRes for archival
|
||||||
|
- Each composition accepts `clipDirectory` prop pointing to recorded footage folder
|
||||||
|
|
||||||
|
**Composition ID naming:** All IDs use lowercase + hyphens only (Remotion constraint). No underscores.
|
||||||
|
|
||||||
|
**Suggested project structure:**
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
compositions/
|
||||||
|
TheFullPlay.tsx
|
||||||
|
ZeroToTrip.tsx
|
||||||
|
RouteRevealed.tsx
|
||||||
|
EveryGame.tsx
|
||||||
|
StadiumQuest.tsx
|
||||||
|
SquadGoals.tsx
|
||||||
|
YourPlaybook.tsx
|
||||||
|
AllSports.tsx
|
||||||
|
DayByDay.tsx
|
||||||
|
YourRules.tsx
|
||||||
|
components/
|
||||||
|
shared/
|
||||||
|
CTAEndCard.tsx
|
||||||
|
TextOverlay.tsx
|
||||||
|
ClipPlayer.tsx
|
||||||
|
SportColorWipe.tsx
|
||||||
|
transitions/
|
||||||
|
WhipPan.tsx
|
||||||
|
ColorWipe.tsx
|
||||||
|
PageTurn.tsx
|
||||||
|
MorphFade.tsx
|
||||||
|
clips/ <- recorded footage
|
||||||
|
audio/
|
||||||
|
music/ <- per-video background tracks
|
||||||
|
sfx/ <- shared SFX library
|
||||||
|
```
|
||||||
234
docs/STORYBOARD_VIDEOS.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# SECTION 2: VIDEO STORYBOARDS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V01: "The Full Play"
|
||||||
|
|
||||||
|
**Concept:** Fast-paced montage showcasing the full breadth of SportsTime
|
||||||
|
**Creative angle:** Rapid-fire tour of every major feature in one video
|
||||||
|
**Target duration:** 15s
|
||||||
|
**Primary feature:** F01 (Trip Planning) | **Secondary:** F07 (Progress), F08 (Achievements)
|
||||||
|
**Design language:** Bold sans-serif, kinetic text reveals, whip-pan cuts, energetic/aspirational
|
||||||
|
**Hook (first 1.5s):** "Plan Epic Sports Road Trips" — large kinetic text over animated home background
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why this shot exists |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.5 | 1.5s | CLIP-001 | Home screen, animated sports icons float, hero card | — | **"Plan Epic Sports Road Trips"** | Fade from black, text kinetic reveal word-by-word | Upbeat music starts, bass swell | Hook — establishes identity and visual polish |
|
||||||
|
| 1.5-2.5 | 1.0s | CLIP-002 | Tap "Start Planning", wizard sheet rises | F01 | — | Quick zoom-cut, sheet slides up | Click SFX | Shows primary CTA action |
|
||||||
|
| 2.5-4.0 | 1.5s | CLIP-005 | Select MLB > NBA > NHL sport chips | F12 | "7 Sports" | Whip pan in | Tap SFX x3 rapid | Shows multi-sport breadth |
|
||||||
|
| 4.0-6.0 | 2.0s | CLIP-009 | Map hero with golden routes + stop markers | F03 | "Optimized Routes" | Zoom-through into map | Bass drop on reveal | Hero visual — the money shot |
|
||||||
|
| 6.0-8.0 | 2.0s | CLIP-011 | Scroll itinerary with game cards (sport color bars) | F04 | "Day-by-Day Plans" | Slide up transition | Steady beat | Shows tangible output |
|
||||||
|
| 8.0-9.5 | 1.5s | CLIP-018 | Progress rings filling for stadiums | F07 | "Track Progress" | Quick cut | Ding SFX | Gamification hook |
|
||||||
|
| 9.5-11.0 | 1.5s | CLIP-021 | Achievement badges grid (gold earned + locked) | F08 | "Earn Badges" | Pop/bounce transition | Achievement SFX | Reward psychology |
|
||||||
|
| 11.0-12.0 | 1.0s | CLIP-013 | Heart/save tap, spring animation | F06 | — | Quick cut | Tap + whoosh | Emotional micro-moment |
|
||||||
|
| 12.0-15.0 | 3.0s | — | App icon + "SportsTime" + App Store badge | — | **"Plan Your Trip Today"** | Scale-in with orange glow, hold | Music resolve, bell ding | CTA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V02: "Zero to Trip"
|
||||||
|
|
||||||
|
**Concept:** Watch a complete trip planned start-to-finish in one seamless flow
|
||||||
|
**Creative angle:** Speed and ease — the whole journey in 14 seconds
|
||||||
|
**Target duration:** 14s
|
||||||
|
**Primary feature:** F01 (Planning Wizard) | **Secondary:** F16 (Planning Modes), F05 (Options)
|
||||||
|
**Design language:** Continuous flow, direction-matched swipes, step counter in corner, efficient/satisfying
|
||||||
|
**Hook (first 1.0s):** "Watch This" — casual, scroll-stopping
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.0 | 1.0s | CLIP-001 | Home screen, hero card | — | **"Watch This"** | Quick fade in | Anticipation build | Hook — creates curiosity |
|
||||||
|
| 1.0-2.0 | 1.0s | CLIP-002 | Tap "Start Planning" | F01 | "Step 1" (small) | Sheet slide up matches direction | Click SFX | Start the flow |
|
||||||
|
| 2.0-3.0 | 1.0s | CLIP-003 | Select "Date Range" mode | F16 | "Step 2" | Cross-dissolve | Tap SFX | Mode selection |
|
||||||
|
| 3.0-4.5 | 1.5s | CLIP-004 | Pick start + end dates | F01 | "Step 3" | Smooth swipe | Scroll SFX | Date selection |
|
||||||
|
| 4.5-5.5 | 1.0s | CLIP-005 | Tap MLB + NBA + NHL | F12 | "Step 4" | Smooth swipe | Tap SFX x3 | Sport selection |
|
||||||
|
| 5.5-7.0 | 1.5s | CLIP-006 | Tap "Plan Trip", spinner | F01 | "Step 5" | Button press effect | Building swell | Trigger planning |
|
||||||
|
| 7.0-8.5 | 1.5s | CLIP-007 | Trip options appear, staggered cards | F05 | "3 routes found!" | Cards slide in from bottom | Pop SFX per card | Algorithm output |
|
||||||
|
| 8.5-10.5 | 2.0s | CLIP-009 | Map hero golden routes | F03 | "Your Trip" | Zoom into map | Bass drop | The payoff |
|
||||||
|
| 10.5-11.5 | 1.0s | CLIP-013 | Save trip (heart) | F06 | — | Quick cut | Heart beat SFX | Commitment |
|
||||||
|
| 11.5-14.0 | 2.5s | — | App icon + "60 Seconds to Your Dream Trip" | — | **"Download SportsTime"** | Scale-in, hold | Music resolve | CTA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V03: "Route Revealed"
|
||||||
|
|
||||||
|
**Concept:** Cinematic focus on the visual beauty of route planning and map output
|
||||||
|
**Creative angle:** Let the map breathe — wanderlust energy
|
||||||
|
**Target duration:** 14s
|
||||||
|
**Primary feature:** F03 (Route Map) | **Secondary:** F13 (Trip Score), F15 (EV Charging)
|
||||||
|
**Design language:** Cinematic, minimal elegant serif, slow dissolves and zooms, wanderlust tone
|
||||||
|
**Hook (first 1.0s):** "Your Route" — minimal, elegant
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.0 | 1.0s | CLIP-002 | Tap "Start Planning" | F01 | **"Your Route"** (serif) | Slow fade in | Soft cinematic music | Hook — premium tone |
|
||||||
|
| 1.0-2.0 | 1.0s | CLIP-005 | Select sports (quick) | F12 | — | Dissolve | Soft taps | Brief planning context |
|
||||||
|
| 2.0-3.0 | 1.0s | CLIP-006 | Tap "Plan Trip" + loading | F01 | "Optimizing..." | Slow dissolve | Building swell | Anticipation |
|
||||||
|
| 3.0-5.5 | 2.5s | CLIP-009 | Map: golden routes appear, markers settle | F03 | — | Slow zoom-in, no text | Bass drop on render | THE shot — cinematic route reveal |
|
||||||
|
| 5.5-7.0 | 1.5s | CLIP-010 | Stats: 5 cities, 8 games, 1200 mi | F14 | — | Gentle slide up | Subtle data SFX | Route context |
|
||||||
|
| 7.0-8.5 | 1.5s | CLIP-035 | Travel: Boston > New York, 215 mi | F18 | "Every mile mapped" | Slide | Soft whoosh | Route detail |
|
||||||
|
| 8.5-10.0 | 1.5s | CLIP-034 | EV chargers expand (green dots) | F15 | "EV-ready routes" | Expand animation | Green ding | Modern differentiator |
|
||||||
|
| 10.0-11.5 | 1.5s | CLIP-014 | Trip score card: "A" grade | F13 | — | Dissolve | Achievement tone | Quality signal |
|
||||||
|
| 11.5-12.5 | 1.0s | CLIP-013 | Save (heart tap) | F06 | — | Quick cut | Heart beat | Commitment |
|
||||||
|
| 12.5-14.0 | 1.5s | — | "SportsTime" + App Store | — | **"Plan Your Route"** | Fade to brand | Music resolve | CTA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V04: "Every Game"
|
||||||
|
|
||||||
|
**Concept:** The massive game database and filtering power
|
||||||
|
**Creative angle:** Sports broadcast aesthetic — ticker style, data richness
|
||||||
|
**Target duration:** 14s
|
||||||
|
**Primary feature:** F02 (Schedule) | **Secondary:** F12 (Multi-Sport), F04 (Itinerary)
|
||||||
|
**Design language:** Sports ticker font, horizontal swipes matching filter direction, powerful/comprehensive
|
||||||
|
**Hook (first 1.5s):** "Every Game. Every Sport." — bold statement
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.5 | 1.5s | CLIP-015 | Schedule tab, sport chips + game list | F02 | **"Every Game. Every Sport."** | Horizontal wipe in | Broadcast music | Hook — data depth |
|
||||||
|
| 1.5-3.5 | 2.0s | CLIP-016 | Tap MLB > NBA > NHL, list updates | F12 | — | Horizontal swipes per tap | Tap SFX + list refresh | Breadth dynamically |
|
||||||
|
| 3.5-5.0 | 1.5s | CLIP-017 | Scroll game list (badges, times) | F02 | "Browse 1000+ games" | Vertical scroll | Scroll SFX | Data richness |
|
||||||
|
| 5.0-6.0 | 1.0s | CLIP-002 | Tap "Start Planning" | F01 | "Then plan a trip" | Quick cut | Click | Transition to planning |
|
||||||
|
| 6.0-7.0 | 1.0s | CLIP-005 | Select sports | F12 | — | Swipe | Taps | Quick planning |
|
||||||
|
| 7.0-8.0 | 1.0s | CLIP-007 | Trip options appear | F05 | — | Cards pop in | Pop SFX | Results |
|
||||||
|
| 8.0-10.0 | 2.0s | CLIP-009 | Map with routes | F03 | "Your optimized route" | Zoom into map | Bass drop | Map reveal |
|
||||||
|
| 10.0-12.0 | 2.0s | CLIP-011 | Scroll itinerary games | F04 | — | Slide up | Steady beat | The plan |
|
||||||
|
| 12.0-12.5 | 0.5s | CLIP-013 | Save (heart, quick) | F06 | — | Quick cut | Tap | Micro-moment |
|
||||||
|
| 12.5-14.0 | 1.5s | — | CTA | — | **"Download SportsTime"** | Scale-in | Resolve | CTA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V05: "Stadium Quest"
|
||||||
|
|
||||||
|
**Concept:** Gamification angle — collect stadiums, earn badges, track progress
|
||||||
|
**Creative angle:** Achievement-unlock aesthetic, gold accents, reward psychology
|
||||||
|
**Target duration:** 15s
|
||||||
|
**Primary feature:** F07 (Progress) | **Secondary:** F08 (Achievements), F11 (Share)
|
||||||
|
**Design language:** Pop/bounce animations, achievement-unlock text style, playful/rewarding
|
||||||
|
**Hook (first 2.0s):** "Track Every Stadium You Visit" — clear value
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-2.0 | 2.0s | CLIP-018 | Progress rings fill on sport buttons | F07 | **"Track Every Stadium You Visit"** | Pop/bounce entrance, rings animate | Achievement music, ring SFX | Hook — gamification promise |
|
||||||
|
| 2.0-4.0 | 2.0s | CLIP-019 | Stadium map (green + gray dots) | F07 | "Your stadium map" | Zoom-to-map | Ping per dot | Visual scope |
|
||||||
|
| 4.0-5.5 | 1.5s | CLIP-020 | Scroll visited chips (green checks) | F07 | — | Horizontal scroll | Scroll SFX | Visited status |
|
||||||
|
| 5.5-7.5 | 2.0s | CLIP-021 | Achievement grid (gold + locked) | F08 | "Earn achievements" | Pop/scale transition | Unlock jingle | Badge showcase |
|
||||||
|
| 7.5-9.0 | 1.5s | CLIP-022 | Tap badge > detail sheet | F08 | — | Sheet slide up | Ding on reveal | Detail moment |
|
||||||
|
| 9.0-10.5 | 1.5s | CLIP-028 | Share preview card | F11 | "Share your quest" | Slide-in right | Share whoosh | Social angle |
|
||||||
|
| 10.5-12.0 | 1.5s | CLIP-009 | Map with routes (plan more trips) | F03 | — | Dissolve | Soft swell | Connect progress to trips |
|
||||||
|
| 12.0-13.0 | 1.0s | CLIP-013 | Save trip | F06 | — | Quick cut | Heart beat | Continuity |
|
||||||
|
| 13.0-15.0 | 2.0s | — | CTA | — | **"Start Your Stadium Quest"** | Pop-in with gold glow | Resolve + achievement tone | CTA matches gamification |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V06: "Squad Goals"
|
||||||
|
|
||||||
|
**Concept:** Social angle — plan trips with friends, vote together
|
||||||
|
**Creative angle:** Messaging/social aesthetic, conversational rhythm
|
||||||
|
**Target duration:** 14s
|
||||||
|
**Primary feature:** F09 (Group Polls) | **Secondary:** F03 (Map), F04 (Itinerary)
|
||||||
|
**Design language:** Rounded friendly type, slide-ins from sides, chat-bubble feel, fun/collaborative
|
||||||
|
**Hook (first 1.5s):** "Plan Trips With Friends" — social proof
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.5 | 1.5s | CLIP-023 | My Trips + saved trips + poll section | F09 | **"Plan Trips With Friends"** | Slide in from left | Social music | Hook — social angle |
|
||||||
|
| 1.5-3.5 | 2.0s | CLIP-024 | Poll creation: select 2-3 trips | F09 | "Create a poll" | Slide transition | Check SFX per pick | Poll setup |
|
||||||
|
| 3.5-5.0 | 1.5s | CLIP-025 | Share code: "AB3K7X" large | F09 | "Share the code" | Pop-in animation | Message SFX | Shareable moment |
|
||||||
|
| 5.0-7.0 | 2.0s | CLIP-026 | Results: bars fill, medals appear | F09 | "Friends vote" | Bars animate in sequence | Drum roll > ding | Payoff — who won |
|
||||||
|
| 7.0-9.0 | 2.0s | CLIP-009 | Winner trip's map | F03 | "Winning trip" | Zoom into map | Celebration SFX | The chosen trip |
|
||||||
|
| 9.0-10.5 | 1.5s | CLIP-011 | Scroll winning trip itinerary | F04 | — | Slide up | Beat | What you'll do |
|
||||||
|
| 10.5-11.5 | 1.0s | CLIP-013 | Save trip | F06 | — | Quick cut | Heart beat | Commitment |
|
||||||
|
| 11.5-14.0 | 2.5s | — | CTA | — | **"Gather Your Squad"** | Slide-in, group icon | Resolve | CTA matches social |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V07: "Your Playbook"
|
||||||
|
|
||||||
|
**Concept:** The trip document you take with you — export and share
|
||||||
|
**Creative angle:** Premium magazine/document aesthetic, polished and professional
|
||||||
|
**Target duration:** 13s
|
||||||
|
**Primary feature:** F10 (PDF Export) | **Secondary:** F11 (Share), F04 (Itinerary)
|
||||||
|
**Design language:** Serif headlines, page-turn transitions, deliberate pacing, premium tone
|
||||||
|
**Hook (first 1.5s):** "Take Your Trip Plan Anywhere" — utility focus
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.5 | 1.5s | CLIP-009 | Map hero establishing shot | F03 | **"Take Your Trip Plan Anywhere"** (serif) | Slow zoom, serif text | Premium music | Hook — utility/premium |
|
||||||
|
| 1.5-3.5 | 2.0s | CLIP-011 | Scroll itinerary Day 1 | F04 | — | Slide up from map | Page turn SFX | What gets exported |
|
||||||
|
| 3.5-5.5 | 2.0s | CLIP-012 | Continue: travel + Day 2 | F04 | "Every detail" | Continuous scroll | Paper slide SFX | Depth |
|
||||||
|
| 5.5-8.0 | 2.5s | CLIP-027 | Export: overlay ring fills, text changes | F10 | "Export as PDF" | Match app animation | Swell > ding at done | Export is the feature |
|
||||||
|
| 8.0-9.5 | 1.5s | CLIP-028 | Share preview card | F11 | "Share anywhere" | Slide-in bottom | Share whoosh | Distribution |
|
||||||
|
| 9.5-11.0 | 1.5s | CLIP-010 | Stats row (cities, games, miles) | F14 | — | Dissolve | Soft data SFX | Grounds value |
|
||||||
|
| 11.0-13.0 | 2.0s | — | CTA | — | **"Your Trip. Your Playbook."** | Page-turn to CTA | Resolve | CTA matches premium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V08: "All Sports"
|
||||||
|
|
||||||
|
**Concept:** Rapid showcase of all 7 sports — color explosion, sport-branded
|
||||||
|
**Creative angle:** Dynamic sport cycling, each sport gets its brand color
|
||||||
|
**Target duration:** 15s
|
||||||
|
**Primary feature:** F12 (Multi-Sport) | **Secondary:** F02 (Schedule), F04 (Itinerary)
|
||||||
|
**Design language:** Bold condensed type, color-wipe between sport colors, dynamic/colorful
|
||||||
|
**Hook (first 1.5s):** "7 Sports. 150+ Stadiums." — bold data point
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.5 | 1.5s | CLIP-015 | Schedule tab, sport chips | F02 | **"7 Sports. 150+ Stadiums."** | Color-wipe entrance | High-energy music | Hook — scale |
|
||||||
|
| 1.5-4.5 | 3.0s | CLIP-016 | Cycle MLB > NBA > NHL filters | F12 | Sport name flashes per tap | Color-wipe per sport color | Bass hit per switch | Dynamic showcase |
|
||||||
|
| 4.5-6.0 | 1.5s | CLIP-017 | Scroll games for current sport | F02 | — | Vertical scroll | Scroll SFX | Game data |
|
||||||
|
| 6.0-7.0 | 1.0s | CLIP-002 | Tap "Start Planning" | F01 | "Mix them all" | Quick cut | Click | Transition |
|
||||||
|
| 7.0-8.0 | 1.0s | CLIP-005 | Select multiple sports | F12 | — | Color flash per sport | Rapid taps | Multi-sport pick |
|
||||||
|
| 8.0-10.0 | 2.0s | CLIP-009 | Map with multi-sport route | F03 | — | Zoom into map | Bass drop | Route reveal |
|
||||||
|
| 10.0-11.0 | 1.0s | CLIP-037 | Sport badges (MLB+NBA+NHL capsules) | F12 | "One trip. Every sport." | Slide up | Pop SFX | Variety on one trip |
|
||||||
|
| 11.0-13.0 | 2.0s | CLIP-011 | Scroll itinerary (mixed sport cards) | F04 | — | Slide up | Beat | Varied game cards |
|
||||||
|
| 13.0-13.5 | 0.5s | CLIP-013 | Save (heart, quick) | F06 | — | Quick cut | Heart | Micro-moment |
|
||||||
|
| 13.5-15.0 | 1.5s | — | CTA | — | **"Every Sport. One App."** | Color-wipe to CTA | Resolve | CTA matches multi-sport |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V09: "Day by Day"
|
||||||
|
|
||||||
|
**Concept:** Slow, deliberate scroll through a beautiful itinerary — the details matter
|
||||||
|
**Creative angle:** Calendar aesthetic, organized and thorough
|
||||||
|
**Target duration:** 16s
|
||||||
|
**Primary feature:** F04 (Itinerary) | **Secondary:** F13 (Score), F15 (EV), F18 (Travel)
|
||||||
|
**Design language:** Clean sans-serif, day headers prominent, continuous scroll, detailed/organized
|
||||||
|
**Hook (first 2.0s):** "Every Detail. Planned." — clean confidence
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-2.0 | 2.0s | CLIP-009 | Map hero establishing trip | F03 | **"Every Detail. Planned."** (serif) | Slow zoom, clean text | Calm organized music | Hook — detail-oriented |
|
||||||
|
| 2.0-3.5 | 1.5s | CLIP-010 | Stats row (cities, games, miles) | F14 | — | Gentle slide up | Data SFX | Trip overview |
|
||||||
|
| 3.5-4.5 | 1.0s | CLIP-037 | Sport badges row | F12 | — | Dissolve | Soft pop | Sports context |
|
||||||
|
| 4.5-7.5 | 3.0s | CLIP-011 | Slow scroll Day 1: header, game cards | F04 | "Day 1" (small) | Continuous vertical scroll | Gentle scroll SFX | Deep itinerary |
|
||||||
|
| 7.5-10.0 | 2.5s | CLIP-012 | Continue: travel, Day 2 header, games | F04 | "Day 2" (small) | Continuous scroll | SFX at day change | Multi-day continuity |
|
||||||
|
| 10.0-11.5 | 1.5s | CLIP-035 | Travel detail (city>city, miles, time) | F18 | "Know every mile" | Highlight zoom | Distance SFX | Logistics |
|
||||||
|
| 11.5-13.0 | 1.5s | CLIP-034 | EV chargers expand | F15 | "EV charging included" | Expand animation | Green ding | Modern feature |
|
||||||
|
| 13.0-14.5 | 1.5s | CLIP-014 | Trip score "A" grade | F13 | "Trip quality: A" | Dissolve | Achievement tone | Quality signal |
|
||||||
|
| 14.5-16.0 | 1.5s | — | CTA | — | **"Plan Every Detail"** | Scroll-to-reveal | Resolve | CTA matches detail |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V10: "Your Rules"
|
||||||
|
|
||||||
|
**Concept:** All the ways to personalize — planning modes, filters, preferences
|
||||||
|
**Creative angle:** Toggle/switch aesthetic, before/after reveals, empowering
|
||||||
|
**Target duration:** 14s
|
||||||
|
**Primary feature:** F16 (Planning Modes) | **Secondary:** F01 (Wizard), F12 (Multi-Sport)
|
||||||
|
**Design language:** Variable weight type, morph/crossfade between states, empowering/flexible
|
||||||
|
**Hook (first 1.0s):** "Your Trip. Your Rules." — empowering
|
||||||
|
|
||||||
|
| Timecode | Dur | Clip ID | On-screen action | Feature | Text overlay | Motion/transition | Audio cue | Why |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 0.0-1.0 | 1.0s | CLIP-001 | Home screen, hero visible | — | **"Your Trip. Your Rules."** | Morph-fade in | Empowering music | Hook — personalization |
|
||||||
|
| 1.0-2.0 | 1.0s | CLIP-002 | Tap "Start Planning" | F01 | — | Morph transition | Click | Open wizard |
|
||||||
|
| 2.0-3.0 | 1.0s | CLIP-003 | Select "Date Range" mode | F16 | "Pick dates?" | Morph/crossfade | Tap | Mode A |
|
||||||
|
| 3.0-4.5 | 1.5s | CLIP-029 | Select "Game First" mode | F16 | "Pick games?" | Quick morph | Tap + morph SFX | Mode B |
|
||||||
|
| 4.5-6.0 | 1.5s | CLIP-030 | Game picker: search, select 2 games | F16 | — | Slide | Search + check SFX | Game-first detail |
|
||||||
|
| 6.0-7.0 | 1.0s | CLIP-004 | Pick dates (abbreviated) | F01 | — | Swipe | Scroll SFX | Dates for any mode |
|
||||||
|
| 7.0-8.5 | 1.5s | CLIP-031 | Select regions: East + Central | F01 | "Choose your region" | Morph | Tap SFX | Geographic filtering |
|
||||||
|
| 8.5-9.5 | 1.0s | CLIP-005 | Select sports (quick) | F12 | — | Swipe | Taps | Sport choice |
|
||||||
|
| 9.5-11.0 | 1.5s | CLIP-009 | Map with routes (result) | F03 | "Built your way" | Zoom into map | Bass drop | Customization payoff |
|
||||||
|
| 11.0-12.0 | 1.0s | CLIP-013 | Save (heart) | F06 | — | Quick cut | Heart | Commitment |
|
||||||
|
| 12.0-14.0 | 2.0s | — | CTA | — | **"Your Trip. Your Way."** | Morph reveal | Resolve | CTA matches personalization |
|
||||||
BIN
docs/SportsTime_Feature_Deck.pptx
Normal file
BIN
docs/SportsTime_Marketing_Deck.pptx
Normal file
BIN
docs/SportsTime_Pitch_Deck.pptx
Normal file
252
docs/generate_sportstime_pitch.scpt
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
using terms from application "Keynote"
|
||||||
|
|
||||||
|
on set_slide_size(the_doc, the_width, the_height)
|
||||||
|
tell application "Keynote"
|
||||||
|
set width of the_doc to the_width
|
||||||
|
set height of the_doc to the_height
|
||||||
|
end tell
|
||||||
|
end set_slide_size
|
||||||
|
|
||||||
|
on add_full_bleed_image(theSlide, imagePath)
|
||||||
|
tell application "Keynote"
|
||||||
|
tell theSlide
|
||||||
|
make new image with properties {file:POSIX file imagePath, position:{0, 0}, width:1920, height:1080}
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end add_full_bleed_image
|
||||||
|
|
||||||
|
on add_image(theSlide, imagePath, posX, posY, imgWidth, imgHeight)
|
||||||
|
tell application "Keynote"
|
||||||
|
tell theSlide
|
||||||
|
make new image with properties {file:POSIX file imagePath, position:{posX, posY}, width:imgWidth, height:imgHeight}
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end add_image
|
||||||
|
|
||||||
|
on style_title(theSlide, titleText, posX, posY, boxW, boxH, fontSize)
|
||||||
|
tell application "Keynote"
|
||||||
|
tell theSlide
|
||||||
|
set position of default title item to {posX, posY}
|
||||||
|
set width of default title item to boxW
|
||||||
|
set height of default title item to boxH
|
||||||
|
set object text of default title item to titleText
|
||||||
|
set font of object text of default title item to "Avenir Next Heavy"
|
||||||
|
set size of object text of default title item to fontSize
|
||||||
|
set color of object text of default title item to {65535, 65535, 65535}
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end style_title
|
||||||
|
|
||||||
|
on style_body(theSlide, bodyText, posX, posY, boxW, boxH, fontSize)
|
||||||
|
tell application "Keynote"
|
||||||
|
tell theSlide
|
||||||
|
set position of default body item to {posX, posY}
|
||||||
|
set width of default body item to boxW
|
||||||
|
set height of default body item to boxH
|
||||||
|
set object text of default body item to bodyText
|
||||||
|
set font of object text of default body item to "Avenir Next Regular"
|
||||||
|
set size of object text of default body item to fontSize
|
||||||
|
set color of object text of default body item to {56000, 56000, 56000}
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end style_body
|
||||||
|
|
||||||
|
on add_text_item(theSlide, textValue, posX, posY, boxW, boxH, fontName, fontSize, colorValue)
|
||||||
|
tell application "Keynote"
|
||||||
|
tell theSlide
|
||||||
|
set t to make new text item with properties {object text:textValue, position:{posX, posY}, width:boxW, height:boxH}
|
||||||
|
set font of object text of t to fontName
|
||||||
|
set size of object text of t to fontSize
|
||||||
|
set color of object text of t to colorValue
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
end add_text_item
|
||||||
|
|
||||||
|
on add_badge(theSlide, badgeText, posX, posY)
|
||||||
|
my add_text_item(theSlide, badgeText, posX, posY, 460, 54, "Avenir Next Demi Bold", 28, {65535, 36200, 12800})
|
||||||
|
end add_badge
|
||||||
|
|
||||||
|
on decorate_with_logo(theSlide, logoPath)
|
||||||
|
my add_image(theSlide, logoPath, 1740, 48, 120, 120)
|
||||||
|
end decorate_with_logo
|
||||||
|
|
||||||
|
on make_styled_slide(theDoc, bgPath, logoPath)
|
||||||
|
tell application "Keynote"
|
||||||
|
set s to make new slide at end of slides of theDoc
|
||||||
|
end tell
|
||||||
|
my add_full_bleed_image(s, bgPath)
|
||||||
|
my decorate_with_logo(s, logoPath)
|
||||||
|
return s
|
||||||
|
end make_styled_slide
|
||||||
|
|
||||||
|
on run argv
|
||||||
|
set currentStep to "start"
|
||||||
|
try
|
||||||
|
if (count of argv) is 0 then
|
||||||
|
error "Usage: osascript generate_sportstime_pitch.scpt /absolute/path/to/SportsTime_Pitch.key"
|
||||||
|
end if
|
||||||
|
|
||||||
|
set output_path to item 1 of argv
|
||||||
|
set project_root to "/Users/treyt/Desktop/code/SportsTime"
|
||||||
|
|
||||||
|
set logo_main to project_root & "/SportsTime/Assets.xcassets/AppIcon.appiconset/master.png"
|
||||||
|
set logo_orbit to project_root & "/SportsTime/Assets.xcassets/AppIcon-orbit.appiconset/icon.png"
|
||||||
|
|
||||||
|
set bg3 to project_root & "/docs/pitch_assets/bg/frame_3_bg.jpg"
|
||||||
|
set bg8 to project_root & "/docs/pitch_assets/bg/frame_8_bg.jpg"
|
||||||
|
set bg13 to project_root & "/docs/pitch_assets/bg/frame_13_bg.jpg"
|
||||||
|
set bg18 to project_root & "/docs/pitch_assets/bg/frame_18_bg.jpg"
|
||||||
|
set bg23 to project_root & "/docs/pitch_assets/bg/frame_23_bg.jpg"
|
||||||
|
set bg29 to project_root & "/docs/pitch_assets/bg/frame_29_bg.jpg"
|
||||||
|
set bg35 to project_root & "/docs/pitch_assets/bg/frame_35_bg.jpg"
|
||||||
|
set bg41 to project_root & "/docs/pitch_assets/bg/frame_41_bg.jpg"
|
||||||
|
set bg48 to project_root & "/docs/pitch_assets/bg/frame_48_bg.jpg"
|
||||||
|
set bg55 to project_root & "/docs/pitch_assets/bg/frame_55_bg.jpg"
|
||||||
|
set bg63 to project_root & "/docs/pitch_assets/bg/frame_63_bg.jpg"
|
||||||
|
set bg71 to project_root & "/docs/pitch_assets/bg/frame_71_bg.jpg"
|
||||||
|
|
||||||
|
set frame3 to project_root & "/docs/pitch_assets/frames/frame_3.png"
|
||||||
|
set frame8 to project_root & "/docs/pitch_assets/frames/frame_8.png"
|
||||||
|
set frame13 to project_root & "/docs/pitch_assets/frames/frame_13.png"
|
||||||
|
set frame18 to project_root & "/docs/pitch_assets/frames/frame_18.png"
|
||||||
|
set frame23 to project_root & "/docs/pitch_assets/frames/frame_23.png"
|
||||||
|
set frame29 to project_root & "/docs/pitch_assets/frames/frame_29.png"
|
||||||
|
set frame35 to project_root & "/docs/pitch_assets/frames/frame_35.png"
|
||||||
|
set frame41 to project_root & "/docs/pitch_assets/frames/frame_41.png"
|
||||||
|
set frame48 to project_root & "/docs/pitch_assets/frames/frame_48.png"
|
||||||
|
set frame55 to project_root & "/docs/pitch_assets/frames/frame_55.png"
|
||||||
|
set frame63 to project_root & "/docs/pitch_assets/frames/frame_63.png"
|
||||||
|
set frame71 to project_root & "/docs/pitch_assets/frames/frame_71.png"
|
||||||
|
|
||||||
|
set currentStep to "create document"
|
||||||
|
tell application "Keynote"
|
||||||
|
activate
|
||||||
|
set deck to make new document with properties {document theme:theme "Basic Black"}
|
||||||
|
end tell
|
||||||
|
|
||||||
|
set currentStep to "set slide size"
|
||||||
|
my set_slide_size(deck, 1920, 1080)
|
||||||
|
|
||||||
|
set currentStep to "slide 1"
|
||||||
|
-- Slide 1: Cover
|
||||||
|
my add_full_bleed_image(slide 1 of deck, bg63)
|
||||||
|
my add_image(slide 1 of deck, logo_orbit, 128, 104, 168, 168)
|
||||||
|
my decorate_with_logo(slide 1 of deck, logo_main)
|
||||||
|
my style_title(slide 1 of deck, "SportsTime", 120, 308, 940, 140, 104)
|
||||||
|
my style_body(slide 1 of deck, "Plan multi-stop sports trips in minutes." & return & "Choose dates. Follow games. Drive smarter.", 124, 466, 880, 260, 42)
|
||||||
|
my add_image(slide 1 of deck, frame63, 1160, 90, 396, 860)
|
||||||
|
my add_image(slide 1 of deck, frame55, 1518, 168, 320, 694)
|
||||||
|
my add_badge(slide 1 of deck, "Investor Pitch Deck", 124, 236)
|
||||||
|
|
||||||
|
set currentStep to "slide 2"
|
||||||
|
-- Slide 2: Problem
|
||||||
|
set s2 to my make_styled_slide(deck, bg3, logo_main)
|
||||||
|
my style_title(s2, "The Problem", 120, 132, 820, 120, 72)
|
||||||
|
my style_body(s2, "Fans use too many tools to build one trip." & return & "• Schedules in one app" & return & "• Routes in another" & return & "• Notes in a spreadsheet", 124, 290, 780, 430, 38)
|
||||||
|
my add_image(s2, frame8, 1060, 110, 410, 886)
|
||||||
|
my add_image(s2, frame3, 1470, 220, 320, 694)
|
||||||
|
my add_badge(s2, "Fragmented Planning Kills Conversion", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 3"
|
||||||
|
-- Slide 3: Solution
|
||||||
|
set s3 to my make_styled_slide(deck, bg13, logo_main)
|
||||||
|
my style_title(s3, "One App. Full Trip Plan.", 120, 132, 950, 130, 70)
|
||||||
|
my style_body(s3, "SportsTime combines schedules, routing, and itinerary output." & return & "Users go from idea to bookable plan in a single flow.", 124, 300, 860, 260, 36)
|
||||||
|
my add_image(s3, frame13, 1120, 92, 396, 860)
|
||||||
|
my add_image(s3, frame18, 1500, 210, 300, 650)
|
||||||
|
my add_badge(s3, "From Search to Itinerary", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 4"
|
||||||
|
-- Slide 4: How It Works
|
||||||
|
set s4 to my make_styled_slide(deck, bg18, logo_main)
|
||||||
|
my style_title(s4, "How It Works", 120, 122, 760, 120, 68)
|
||||||
|
my style_body(s4, "1. Pick planning mode and date range" & return & "2. Filter sports and games" & return & "3. Review route + itinerary options" & return & "4. Save and share", 124, 272, 700, 420, 36)
|
||||||
|
my add_image(s4, frame18, 930, 138, 300, 650)
|
||||||
|
my add_image(s4, frame23, 1240, 108, 320, 694)
|
||||||
|
my add_image(s4, frame29, 1570, 172, 300, 650)
|
||||||
|
my add_badge(s4, "4-Step Core Loop", 124, 84)
|
||||||
|
|
||||||
|
set currentStep to "slide 5"
|
||||||
|
-- Slide 5: Planning Modes
|
||||||
|
set s5 to my make_styled_slide(deck, bg23, logo_main)
|
||||||
|
my style_title(s5, "5 Planning Modes", 120, 132, 760, 120, 68)
|
||||||
|
my style_body(s5, "By Dates | By Games | By Route" & return & "Follow Team | By Teams", 124, 292, 780, 180, 40)
|
||||||
|
my add_text_item(s5, "Matches real fan intent, not one fixed workflow.", 124, 462, 760, 120, "Avenir Next Demi Bold", 30, {65535, 39000, 12000})
|
||||||
|
my add_image(s5, frame29, 1080, 96, 396, 860)
|
||||||
|
my add_image(s5, frame35, 1460, 188, 300, 650)
|
||||||
|
my add_badge(s5, "Flexible Planning = Higher Completion", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 6"
|
||||||
|
-- Slide 6: Date-Driven UX
|
||||||
|
set s6 to my make_styled_slide(deck, bg29, logo_main)
|
||||||
|
my style_title(s6, "Date-Driven Planning", 120, 132, 850, 120, 66)
|
||||||
|
my style_body(s6, "Choose a travel window, then discover game combinations" & return & "that are actually drivable and time-feasible.", 124, 292, 860, 220, 34)
|
||||||
|
my add_text_item(s6, "This is the ad hook: choose dates -> follow games.", 124, 520, 900, 110, "Avenir Next Demi Bold", 30, {65535, 39000, 12000})
|
||||||
|
my add_image(s6, frame41, 1120, 94, 396, 860)
|
||||||
|
my add_image(s6, frame48, 1500, 216, 290, 628)
|
||||||
|
my add_badge(s6, "Core Conversion Moment", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 7"
|
||||||
|
-- Slide 7: Itinerary Output
|
||||||
|
set s7 to my make_styled_slide(deck, bg35, logo_main)
|
||||||
|
my style_title(s7, "Itinerary in Seconds", 120, 132, 840, 120, 66)
|
||||||
|
my style_body(s7, "Auto-ranked options show stops, games, and drive segments." & return & "Users compare routes without doing manual math.", 124, 292, 820, 230, 34)
|
||||||
|
my add_image(s7, frame35, 1060, 96, 396, 860)
|
||||||
|
my add_image(s7, frame55, 1450, 220, 290, 628)
|
||||||
|
my add_badge(s7, "Route Quality + Speed", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 8"
|
||||||
|
-- Slide 8: Retention Surfaces
|
||||||
|
set s8 to my make_styled_slide(deck, bg41, logo_main)
|
||||||
|
my style_title(s8, "Retention Surfaces", 120, 132, 860, 120, 66)
|
||||||
|
my style_body(s8, "Beyond planning: progress tracking, achievements," & return & "saved trips, and repeat planning behavior.", 124, 292, 820, 220, 34)
|
||||||
|
my add_image(s8, frame41, 1020, 96, 396, 860)
|
||||||
|
my add_image(s8, frame63, 1400, 216, 290, 628)
|
||||||
|
my add_badge(s8, "Plan -> Track -> Share -> Replan", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 9"
|
||||||
|
-- Slide 9: Shareability
|
||||||
|
set s9 to my make_styled_slide(deck, bg48, logo_main)
|
||||||
|
my style_title(s9, "Built-In Shareability", 120, 132, 860, 120, 66)
|
||||||
|
my style_body(s9, "Trip cards and PDF exports make plans social." & return & "Every shared itinerary becomes organic distribution.", 124, 292, 840, 220, 34)
|
||||||
|
my add_image(s9, frame48, 1060, 96, 396, 860)
|
||||||
|
my add_image(s9, frame29, 1460, 216, 290, 628)
|
||||||
|
my add_badge(s9, "Viral Output Layer", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 10"
|
||||||
|
-- Slide 10: Technical Moat
|
||||||
|
set s10 to my make_styled_slide(deck, bg55, logo_main)
|
||||||
|
my style_title(s10, "Technical Moat", 120, 132, 800, 120, 64)
|
||||||
|
my style_body(s10, "Offline-first data + sync architecture:" & return & "• Bundled canonical sports data" & return & "• SwiftData local persistence" & return & "• CloudKit refresh in background", 124, 292, 860, 320, 34)
|
||||||
|
my add_image(s10, frame55, 1080, 96, 396, 860)
|
||||||
|
my add_badge(s10, "Reliability Drives Trust", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 11"
|
||||||
|
-- Slide 11: Business Model
|
||||||
|
set s11 to my make_styled_slide(deck, bg71, logo_main)
|
||||||
|
my style_title(s11, "Monetization + GTM", 120, 132, 860, 120, 64)
|
||||||
|
my style_body(s11, "Freemium acquisition -> Pro conversion" & return & "• Subscription products already integrated" & return & "• Performance creative via short-form video" & return & "• Community and share-led growth loops", 124, 292, 860, 330, 34)
|
||||||
|
my add_image(s11, frame71, 1080, 96, 396, 860)
|
||||||
|
my add_badge(s11, "Scale Through Content + Conversion", 124, 92)
|
||||||
|
|
||||||
|
set currentStep to "slide 12"
|
||||||
|
-- Slide 12: Ask
|
||||||
|
set s12 to my make_styled_slide(deck, bg8, logo_orbit)
|
||||||
|
my add_image(s12, logo_main, 130, 92, 156, 156)
|
||||||
|
my style_title(s12, "The Ask", 120, 282, 760, 120, 84)
|
||||||
|
my style_body(s12, "Funding to scale paid growth, creative velocity," & return & "and product execution against retention KPIs.", 124, 454, 900, 220, 40)
|
||||||
|
my add_text_item(s12, "Next: replace with your live traction metrics before meetings.", 124, 704, 980, 90, "Avenir Next Demi Bold", 30, {65535, 39000, 12000})
|
||||||
|
my add_image(s12, frame8, 1210, 100, 396, 860)
|
||||||
|
my add_badge(s12, "SportsTime", 124, 236)
|
||||||
|
|
||||||
|
set currentStep to "save"
|
||||||
|
tell application "Keynote"
|
||||||
|
save deck in POSIX file output_path
|
||||||
|
close deck
|
||||||
|
end tell
|
||||||
|
on error errMsg number errNum
|
||||||
|
error "Step: " & currentStep & " | " & errMsg & " (" & errNum & ")"
|
||||||
|
end try
|
||||||
|
end run
|
||||||
|
|
||||||
|
end using terms from
|
||||||
BIN
docs/pitch_assets/bg/frame_13_bg.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/pitch_assets/bg/frame_18_bg.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/pitch_assets/bg/frame_23_bg.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/pitch_assets/bg/frame_29_bg.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/pitch_assets/bg/frame_35_bg.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/pitch_assets/bg/frame_3_bg.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/pitch_assets/bg/frame_41_bg.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/pitch_assets/bg/frame_48_bg.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/pitch_assets/bg/frame_55_bg.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/pitch_assets/bg/frame_63_bg.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/pitch_assets/bg/frame_71_bg.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/pitch_assets/bg/frame_8_bg.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/pitch_assets/frames/frame_13.png
Normal file
|
After Width: | Height: | Size: 610 KiB |
BIN
docs/pitch_assets/frames/frame_18.png
Normal file
|
After Width: | Height: | Size: 619 KiB |
BIN
docs/pitch_assets/frames/frame_23.png
Normal file
|
After Width: | Height: | Size: 610 KiB |
BIN
docs/pitch_assets/frames/frame_29.png
Normal file
|
After Width: | Height: | Size: 620 KiB |
BIN
docs/pitch_assets/frames/frame_3.png
Normal file
|
After Width: | Height: | Size: 711 KiB |
BIN
docs/pitch_assets/frames/frame_35.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
docs/pitch_assets/frames/frame_41.png
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
docs/pitch_assets/frames/frame_48.png
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
docs/pitch_assets/frames/frame_55.png
Normal file
|
After Width: | Height: | Size: 843 KiB |
BIN
docs/pitch_assets/frames/frame_63.png
Normal file
|
After Width: | Height: | Size: 838 KiB |
BIN
docs/pitch_assets/frames/frame_71.png
Normal file
|
After Width: | Height: | Size: 813 KiB |
BIN
docs/pitch_assets/frames/frame_8.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
screens/flow.png
|
Before Width: | Height: | Size: 1.7 MiB |