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