chore: commit all pending changes

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

View File

@@ -40,7 +40,13 @@
"Bash(echo:*)", "Bash(echo:*)",
"Bash(ffprobe:*)", "Bash(ffprobe:*)",
"WebFetch(domain:www.cbssports.com)", "WebFetch(domain:www.cbssports.com)",
"WebFetch(domain:www.mlb.com)" "WebFetch(domain:www.mlb.com)",
"Bash(git checkout:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:swiftpackageindex.com)",
"WebFetch(domain:posthog.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(swift package add-dependency:*)"
] ]
} }
} }

View 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
}
}

View File

@@ -10,7 +10,13 @@ import CloudKit
// MARK: - Record Type Constants // MARK: - Record Type Constants
enum CKRecordType { private extension String {
nonisolated var ckTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
nonisolated enum CKRecordType {
static let team = "Team" static let team = "Team"
static let stadium = "Stadium" static let stadium = "Stadium"
static let game = "Game" static let game = "Game"
@@ -25,7 +31,7 @@ enum CKRecordType {
// MARK: - CKTeam // MARK: - CKTeam
struct CKTeam { nonisolated struct CKTeam {
static let idKey = "teamId" static let idKey = "teamId"
static let canonicalIdKey = "canonicalId" static let canonicalIdKey = "canonicalId"
static let nameKey = "name" static let nameKey = "name"
@@ -62,12 +68,22 @@ struct CKTeam {
/// The canonical ID string from CloudKit (e.g., "team_nba_atl") /// The canonical ID string from CloudKit (e.g., "team_nba_atl")
var canonicalId: String? { var canonicalId: String? {
record[CKTeam.canonicalIdKey] as? String if let explicit = (record[CKTeam.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
let fallback = record.recordID.recordName.ckTrimmed
return fallback.isEmpty ? nil : fallback
} }
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena") /// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
var stadiumCanonicalId: String? { var stadiumCanonicalId: String? {
record[CKTeam.stadiumCanonicalIdKey] as? String if let explicit = (record[CKTeam.stadiumCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
if let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference {
return stadiumRef.recordID.recordName.ckTrimmed
}
return nil
} }
/// The conference canonical ID string from CloudKit (e.g., "nba_eastern") /// The conference canonical ID string from CloudKit (e.g., "nba_eastern")
@@ -82,16 +98,18 @@ struct CKTeam {
var team: Team? { var team: Team? {
// Use teamId field, or fall back to record name // Use teamId field, or fall back to record name
let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName let id = ((record[CKTeam.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let abbreviation = (record[CKTeam.abbreviationKey] as? String)?.ckTrimmed
let sportRaw = (record[CKTeam.sportKey] as? String)?.ckTrimmed
let city = (record[CKTeam.cityKey] as? String)?.ckTrimmed
guard !id.isEmpty, guard !id.isEmpty,
let abbreviation = record[CKTeam.abbreviationKey] as? String, let abbreviation, !abbreviation.isEmpty,
let sportRaw = record[CKTeam.sportKey] as? String, let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased()),
let sport = Sport(rawValue: sportRaw), let city, !city.isEmpty
let city = record[CKTeam.cityKey] as? String
else { return nil } else { return nil }
// Name defaults to abbreviation if not provided // Name defaults to abbreviation if not provided
let name = record[CKTeam.nameKey] as? String ?? abbreviation let name = ((record[CKTeam.nameKey] as? String)?.ckTrimmed).flatMap { $0.isEmpty ? nil : $0 } ?? abbreviation
// Stadium reference is optional - use placeholder string if not present // Stadium reference is optional - use placeholder string if not present
let stadiumId: String let stadiumId: String
@@ -122,7 +140,7 @@ struct CKTeam {
// MARK: - CKStadium // MARK: - CKStadium
struct CKStadium { nonisolated struct CKStadium {
static let idKey = "stadiumId" static let idKey = "stadiumId"
static let canonicalIdKey = "canonicalId" static let canonicalIdKey = "canonicalId"
static let nameKey = "name" static let nameKey = "name"
@@ -157,25 +175,31 @@ struct CKStadium {
/// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena") /// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
var canonicalId: String? { var canonicalId: String? {
record[CKStadium.canonicalIdKey] as? String if let explicit = (record[CKStadium.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
let fallback = record.recordID.recordName.ckTrimmed
return fallback.isEmpty ? nil : fallback
} }
var stadium: Stadium? { var stadium: Stadium? {
// Use stadiumId field, or fall back to record name // Use stadiumId field, or fall back to record name
let id = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName let id = ((record[CKStadium.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let name = (record[CKStadium.nameKey] as? String)?.ckTrimmed
let city = (record[CKStadium.cityKey] as? String)?.ckTrimmed
guard !id.isEmpty, guard !id.isEmpty,
let name = record[CKStadium.nameKey] as? String, let name, !name.isEmpty,
let city = record[CKStadium.cityKey] as? String let city, !city.isEmpty
else { return nil } else { return nil }
// These fields are optional in CloudKit // These fields are optional in CloudKit
let state = record[CKStadium.stateKey] as? String ?? "" let state = ((record[CKStadium.stateKey] as? String)?.ckTrimmed) ?? ""
let location = record[CKStadium.locationKey] as? CLLocation let location = record[CKStadium.locationKey] as? CLLocation
let capacity = record[CKStadium.capacityKey] as? Int ?? 0 let capacity = record[CKStadium.capacityKey] as? Int ?? 0
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) } let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
let sportRaw = record[CKStadium.sportKey] as? String ?? "MLB" let sportRaw = ((record[CKStadium.sportKey] as? String)?.ckTrimmed).flatMap { $0.isEmpty ? nil : $0 } ?? "MLB"
let sport = Sport(rawValue: sportRaw) ?? .mlb let sport = Sport(rawValue: sportRaw.uppercased()) ?? .mlb
let timezoneIdentifier = record[CKStadium.timezoneIdentifierKey] as? String let timezoneIdentifier = (record[CKStadium.timezoneIdentifierKey] as? String)?.ckTrimmed
return Stadium( return Stadium(
id: id, id: id,
@@ -195,7 +219,7 @@ struct CKStadium {
// MARK: - CKGame // MARK: - CKGame
struct CKGame { nonisolated struct CKGame {
static let idKey = "gameId" static let idKey = "gameId"
static let canonicalIdKey = "canonicalId" static let canonicalIdKey = "canonicalId"
static let homeTeamRefKey = "homeTeamRef" static let homeTeamRefKey = "homeTeamRef"
@@ -232,31 +256,64 @@ struct CKGame {
/// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc") /// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc")
var canonicalId: String? { var canonicalId: String? {
record[CKGame.canonicalIdKey] as? String if let explicit = (record[CKGame.canonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
let fallback = record.recordID.recordName.ckTrimmed
return fallback.isEmpty ? nil : fallback
} }
/// The home team canonical ID string from CloudKit (e.g., "team_nba_okc") /// The home team canonical ID string from CloudKit (e.g., "team_nba_okc")
var homeTeamCanonicalId: String? { var homeTeamCanonicalId: String? {
record[CKGame.homeTeamCanonicalIdKey] as? String if let explicit = (record[CKGame.homeTeamCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
if let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference {
return homeRef.recordID.recordName.ckTrimmed
}
return nil
} }
/// The away team canonical ID string from CloudKit (e.g., "team_nba_hou") /// The away team canonical ID string from CloudKit (e.g., "team_nba_hou")
var awayTeamCanonicalId: String? { var awayTeamCanonicalId: String? {
record[CKGame.awayTeamCanonicalIdKey] as? String if let explicit = (record[CKGame.awayTeamCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
if let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference {
return awayRef.recordID.recordName.ckTrimmed
}
return nil
} }
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_paycom_center") /// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_paycom_center")
var stadiumCanonicalId: String? { var stadiumCanonicalId: String? {
record[CKGame.stadiumCanonicalIdKey] as? String if let explicit = (record[CKGame.stadiumCanonicalIdKey] as? String)?.ckTrimmed, !explicit.isEmpty {
return explicit
}
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
return stadiumRef.recordID.recordName.ckTrimmed
}
return nil
} }
func game(homeTeamId: String, awayTeamId: String, stadiumId: String) -> Game? { func game(homeTeamId: String, awayTeamId: String, stadiumId: String) -> Game? {
let id = (record[CKGame.idKey] as? String) ?? record.recordID.recordName let id = ((record[CKGame.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let sportRaw = (record[CKGame.sportKey] as? String)?.ckTrimmed
let season: String
if let seasonString = (record[CKGame.seasonKey] as? String)?.ckTrimmed, !seasonString.isEmpty {
season = seasonString
} else if let seasonInt = record[CKGame.seasonKey] as? Int {
season = String(seasonInt)
} else if let seasonInt64 = record[CKGame.seasonKey] as? Int64 {
season = String(seasonInt64)
} else if let seasonNumber = record[CKGame.seasonKey] as? NSNumber {
season = seasonNumber.stringValue
} else {
return nil
}
guard !id.isEmpty, guard !id.isEmpty,
let dateTime = record[CKGame.dateTimeKey] as? Date, let dateTime = record[CKGame.dateTimeKey] as? Date,
let sportRaw = record[CKGame.sportKey] as? String, let sportRaw, let sport = Sport(rawValue: sportRaw.uppercased())
let sport = Sport(rawValue: sportRaw),
let season = record[CKGame.seasonKey] as? String
else { return nil } else { return nil }
return Game( return Game(
@@ -267,7 +324,13 @@ struct CKGame {
dateTime: dateTime, dateTime: dateTime,
sport: sport, sport: sport,
season: season, season: season,
isPlayoff: (record[CKGame.isPlayoffKey] as? Int) == 1, isPlayoff: {
if let value = record[CKGame.isPlayoffKey] as? Int { return value == 1 }
if let value = record[CKGame.isPlayoffKey] as? Int64 { return value == 1 }
if let value = record[CKGame.isPlayoffKey] as? NSNumber { return value.intValue == 1 }
if let value = record[CKGame.isPlayoffKey] as? Bool { return value }
return false
}(),
broadcastInfo: record[CKGame.broadcastInfoKey] as? String broadcastInfo: record[CKGame.broadcastInfoKey] as? String
) )
} }
@@ -275,7 +338,7 @@ struct CKGame {
// MARK: - CKLeagueStructure // MARK: - CKLeagueStructure
struct CKLeagueStructure { nonisolated struct CKLeagueStructure {
static let idKey = "structureId" static let idKey = "structureId"
static let sportKey = "sport" static let sportKey = "sport"
static let typeKey = "type" static let typeKey = "type"
@@ -308,15 +371,18 @@ struct CKLeagueStructure {
/// Convert to SwiftData model for local storage /// Convert to SwiftData model for local storage
func toModel() -> LeagueStructureModel? { func toModel() -> LeagueStructureModel? {
guard let id = record[CKLeagueStructure.idKey] as? String, let id = ((record[CKLeagueStructure.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let sport = record[CKLeagueStructure.sportKey] as? String, let sport = (record[CKLeagueStructure.sportKey] as? String)?.ckTrimmed.uppercased()
guard !id.isEmpty,
let sport, !sport.isEmpty,
let typeRaw = record[CKLeagueStructure.typeKey] as? String, let typeRaw = record[CKLeagueStructure.typeKey] as? String,
let structureType = LeagueStructureType(rawValue: typeRaw), let structureType = LeagueStructureType(rawValue: typeRaw.lowercased()),
let name = record[CKLeagueStructure.nameKey] as? String let name = (record[CKLeagueStructure.nameKey] as? String)?.ckTrimmed,
!name.isEmpty
else { return nil } else { return nil }
let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String let abbreviation = (record[CKLeagueStructure.abbreviationKey] as? String)?.ckTrimmed
let parentId = record[CKLeagueStructure.parentIdKey] as? String let parentId = (record[CKLeagueStructure.parentIdKey] as? String)?.ckTrimmed
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0 let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
@@ -337,7 +403,7 @@ struct CKLeagueStructure {
// MARK: - CKSport // MARK: - CKSport
struct CKSport { nonisolated struct CKSport {
static let idKey = "sportId" static let idKey = "sportId"
static let abbreviationKey = "abbreviation" static let abbreviationKey = "abbreviation"
static let displayNameKey = "displayName" static let displayNameKey = "displayName"
@@ -357,16 +423,49 @@ struct CKSport {
/// Convert to CanonicalSport for local storage /// Convert to CanonicalSport for local storage
func toCanonical() -> CanonicalSport? { func toCanonical() -> CanonicalSport? {
guard let id = record[CKSport.idKey] as? String, let id = ((record[CKSport.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let abbreviation = record[CKSport.abbreviationKey] as? String, let seasonStartMonth: Int?
let displayName = record[CKSport.displayNameKey] as? String, if let intValue = record[CKSport.seasonStartMonthKey] as? Int {
let iconName = record[CKSport.iconNameKey] as? String, seasonStartMonth = intValue
let colorHex = record[CKSport.colorHexKey] as? String, } else if let int64Value = record[CKSport.seasonStartMonthKey] as? Int64 {
let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int, seasonStartMonth = Int(int64Value)
let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int } else if let numberValue = record[CKSport.seasonStartMonthKey] as? NSNumber {
seasonStartMonth = numberValue.intValue
} else {
seasonStartMonth = nil
}
let seasonEndMonth: Int?
if let intValue = record[CKSport.seasonEndMonthKey] as? Int {
seasonEndMonth = intValue
} else if let int64Value = record[CKSport.seasonEndMonthKey] as? Int64 {
seasonEndMonth = Int(int64Value)
} else if let numberValue = record[CKSport.seasonEndMonthKey] as? NSNumber {
seasonEndMonth = numberValue.intValue
} else {
seasonEndMonth = nil
}
guard !id.isEmpty,
let abbreviation = (record[CKSport.abbreviationKey] as? String)?.ckTrimmed,
!abbreviation.isEmpty,
let displayName = (record[CKSport.displayNameKey] as? String)?.ckTrimmed,
!displayName.isEmpty,
let iconName = (record[CKSport.iconNameKey] as? String)?.ckTrimmed,
!iconName.isEmpty,
let colorHex = (record[CKSport.colorHexKey] as? String)?.ckTrimmed,
!colorHex.isEmpty,
let seasonStartMonth,
let seasonEndMonth
else { return nil } else { return nil }
let isActive = (record[CKSport.isActiveKey] as? Int ?? 1) == 1 let isActive: Bool = {
if let value = record[CKSport.isActiveKey] as? Int { return value == 1 }
if let value = record[CKSport.isActiveKey] as? Int64 { return value == 1 }
if let value = record[CKSport.isActiveKey] as? NSNumber { return value.intValue == 1 }
if let value = record[CKSport.isActiveKey] as? Bool { return value }
return true
}()
let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
@@ -388,7 +487,7 @@ struct CKSport {
// MARK: - CKStadiumAlias // MARK: - CKStadiumAlias
struct CKStadiumAlias { nonisolated struct CKStadiumAlias {
static let aliasNameKey = "aliasName" static let aliasNameKey = "aliasName"
static let stadiumCanonicalIdKey = "stadiumCanonicalId" static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let validFromKey = "validFrom" static let validFromKey = "validFrom"
@@ -415,8 +514,10 @@ struct CKStadiumAlias {
/// Convert to SwiftData model for local storage /// Convert to SwiftData model for local storage
func toModel() -> StadiumAlias? { func toModel() -> StadiumAlias? {
guard let aliasName = record[CKStadiumAlias.aliasNameKey] as? String, let aliasName = ((record[CKStadiumAlias.aliasNameKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let stadiumCanonicalId = record[CKStadiumAlias.stadiumCanonicalIdKey] as? String guard !aliasName.isEmpty,
let stadiumCanonicalId = (record[CKStadiumAlias.stadiumCanonicalIdKey] as? String)?.ckTrimmed,
!stadiumCanonicalId.isEmpty
else { return nil } else { return nil }
let validFrom = record[CKStadiumAlias.validFromKey] as? Date let validFrom = record[CKStadiumAlias.validFromKey] as? Date
@@ -437,7 +538,7 @@ struct CKStadiumAlias {
// MARK: - CKTeamAlias // MARK: - CKTeamAlias
struct CKTeamAlias { nonisolated struct CKTeamAlias {
static let idKey = "aliasId" static let idKey = "aliasId"
static let teamCanonicalIdKey = "teamCanonicalId" static let teamCanonicalIdKey = "teamCanonicalId"
static let aliasTypeKey = "aliasType" static let aliasTypeKey = "aliasType"
@@ -468,11 +569,14 @@ struct CKTeamAlias {
/// Convert to SwiftData model for local storage /// Convert to SwiftData model for local storage
func toModel() -> TeamAlias? { func toModel() -> TeamAlias? {
guard let id = record[CKTeamAlias.idKey] as? String, let id = ((record[CKTeamAlias.idKey] as? String)?.ckTrimmed) ?? record.recordID.recordName.ckTrimmed
let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String, guard !id.isEmpty,
let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String, let teamCanonicalId = (record[CKTeamAlias.teamCanonicalIdKey] as? String)?.ckTrimmed,
let aliasType = TeamAliasType(rawValue: aliasTypeRaw), !teamCanonicalId.isEmpty,
let aliasValue = record[CKTeamAlias.aliasValueKey] as? String let aliasTypeRaw = (record[CKTeamAlias.aliasTypeKey] as? String)?.ckTrimmed,
let aliasType = TeamAliasType(rawValue: aliasTypeRaw.lowercased()),
let aliasValue = (record[CKTeamAlias.aliasValueKey] as? String)?.ckTrimmed,
!aliasValue.isEmpty
else { return nil } else { return nil }
let validFrom = record[CKTeamAlias.validFromKey] as? Date let validFrom = record[CKTeamAlias.validFromKey] as? Date
@@ -495,7 +599,7 @@ struct CKTeamAlias {
// MARK: - CKTripPoll // MARK: - CKTripPoll
struct CKTripPoll { nonisolated struct CKTripPoll {
static let pollIdKey = "pollId" static let pollIdKey = "pollId"
static let titleKey = "title" static let titleKey = "title"
static let ownerIdKey = "ownerId" static let ownerIdKey = "ownerId"
@@ -594,7 +698,7 @@ struct CKTripPoll {
// MARK: - CKPollVote // MARK: - CKPollVote
struct CKPollVote { nonisolated struct CKPollVote {
static let voteIdKey = "voteId" static let voteIdKey = "voteId"
static let pollIdKey = "pollId" static let pollIdKey = "pollId"
static let voterIdKey = "voterId" static let voterIdKey = "voterId"
@@ -640,4 +744,3 @@ struct CKPollVote {
) )
} }
} }

View File

@@ -15,7 +15,7 @@ import CryptoKit
/// Schema version constants for canonical data models. /// Schema version constants for canonical data models.
/// Marked nonisolated to allow access from any isolation domain. /// Marked nonisolated to allow access from any isolation domain.
nonisolated enum SchemaVersion { nonisolated enum SchemaVersion {
static let current: Int = 1 static let current: Int = 2
static let minimumSupported: Int = 1 static let minimumSupported: Int = 1
} }

View File

@@ -39,8 +39,27 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable: Any], didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) { ) {
// Handle CloudKit subscription notification guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) as? CKQueryNotification,
// TODO: Re-implement subscription handling for ItineraryItem changes let subscriptionID = notification.subscriptionID,
completionHandler(.noData) CloudKitService.canonicalSubscriptionIDs.contains(subscriptionID),
let recordType = CloudKitService.recordType(forSubscriptionID: subscriptionID) else {
completionHandler(.noData)
return
}
Task { @MainActor in
var changed = false
if notification.queryNotificationReason == .recordDeleted,
let recordID = notification.recordID {
changed = await BackgroundSyncManager.shared.applyDeletionHint(
recordType: recordType,
recordName: recordID.recordName
)
}
let updated = await BackgroundSyncManager.shared.triggerSyncFromPushNotification(subscriptionID: subscriptionID)
completionHandler((changed || updated) ? .newData : .noData)
}
} }
} }

View File

@@ -15,6 +15,17 @@ import os
@MainActor @MainActor
final class BackgroundSyncManager { final class BackgroundSyncManager {
enum SyncTriggerError: Error, LocalizedError {
case modelContainerNotConfigured
var errorDescription: String? {
switch self {
case .modelContainerNotConfigured:
return "Sync is unavailable because the model container is not configured."
}
}
}
// MARK: - Task Identifiers // MARK: - Task Identifiers
/// Background app refresh task - runs periodically (system decides frequency) /// Background app refresh task - runs periodically (system decides frequency)
@@ -163,7 +174,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success - progress was saved task.setTaskCompleted(success: true) // Partial success - progress was saved
} else { } else {
logger.info("Background refresh completed: \(result.totalUpdated) items updated") logger.info("Background refresh completed: \(result.totalUpdated) items updated")
task.setTaskCompleted(success: !result.isEmpty) task.setTaskCompleted(success: true)
} }
} catch { } catch {
currentCancellationToken = nil currentCancellationToken = nil
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success task.setTaskCompleted(success: true) // Partial success
} else { } else {
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)") logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess) task.setTaskCompleted(success: true)
} }
} catch { } catch {
currentCancellationToken = nil currentCancellationToken = nil
@@ -291,6 +302,191 @@ final class BackgroundSyncManager {
} }
} }
/// Trigger sync from a CloudKit push notification.
/// Returns true if local data changed and UI should refresh.
@MainActor
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
guard let container = modelContainer else {
logger.error("Push sync: No model container configured")
return false
}
if let subscriptionID {
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
} else {
logger.info("Triggering sync from CloudKit push")
}
do {
let result = try await performSync(context: container.mainContext)
return !result.isEmpty || result.wasCancelled
} catch {
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
return false
}
}
/// Apply a targeted local deletion for records removed remotely.
/// This covers deletions which are not returned by date-based delta queries.
@MainActor
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
guard let container = modelContainer else {
logger.error("Deletion hint: No model container configured")
return false
}
let context = container.mainContext
var changed = false
do {
switch recordType {
case CKRecordType.game:
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.canonicalId == recordName && game.deprecatedAt == nil
}
)
if let game = try context.fetch(descriptor).first {
game.deprecatedAt = Date()
game.deprecationReason = "Deleted in CloudKit"
changed = true
}
case CKRecordType.team:
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.canonicalId == recordName && team.deprecatedAt == nil
}
)
if let team = try context.fetch(descriptor).first {
team.deprecatedAt = Date()
team.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced team disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent team deleted in CloudKit"
changed = true
}
case CKRecordType.stadium:
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
}
)
if let stadium = try context.fetch(descriptor).first {
stadium.deprecatedAt = Date()
stadium.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced stadium disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent stadium deleted in CloudKit"
changed = true
}
case CKRecordType.leagueStructure:
let descriptor = FetchDescriptor<LeagueStructureModel>(
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.teamAlias:
let descriptor = FetchDescriptor<TeamAlias>(
predicate: #Predicate<TeamAlias> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.stadiumAlias:
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.sport:
let descriptor = FetchDescriptor<CanonicalSport>(
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
)
if let sport = try context.fetch(descriptor).first, sport.isActive {
sport.isActive = false
sport.lastModified = Date()
changed = true
}
default:
break
}
guard changed else { return false }
try context.save()
await AppDataProvider.shared.loadInitialData()
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
return true
} catch {
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
return false
}
}
/// Trigger a manual full sync from settings or other explicit user action.
@MainActor
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
guard let container = modelContainer else {
throw SyncTriggerError.modelContainerNotConfigured
}
let context = container.mainContext
let syncService = CanonicalSyncService()
let syncState = SyncState.current(in: context)
if !syncState.syncEnabled {
syncService.resumeSync(context: context)
}
return try await performSync(context: context)
}
/// Register CloudKit subscriptions for canonical data updates.
@MainActor
func ensureCanonicalSubscriptions() async {
do {
try await CloudKitService.shared.subscribeToAllUpdates()
logger.info("CloudKit subscriptions ensured for canonical sync")
} catch {
logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)")
}
}
/// Clean up old game data (older than 1 year). /// Clean up old game data (older than 1 year).
@MainActor @MainActor
private func performCleanup(context: ModelContext) async -> Bool { private func performCleanup(context: ModelContext) async -> Bool {

View File

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

View File

@@ -78,15 +78,17 @@ actor CanonicalSyncService {
let startTime = Date() let startTime = Date()
let syncState = SyncState.current(in: context) let syncState = SyncState.current(in: context)
// Prevent concurrent syncs // Prevent concurrent syncs, but auto-heal stale "in progress" state.
guard !syncState.syncInProgress else { if syncState.syncInProgress {
throw SyncError.syncAlreadyInProgress let staleSyncTimeout: TimeInterval = 15 * 60
if let lastAttempt = syncState.lastSyncAttempt,
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
throw SyncError.syncAlreadyInProgress
}
SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag")
syncState.syncInProgress = false
} }
#if DEBUG
SyncStatusMonitor.shared.syncStarted()
#endif
// Check if sync is enabled // Check if sync is enabled
guard syncState.syncEnabled else { guard syncState.syncEnabled else {
return SyncResult( return SyncResult(
@@ -102,6 +104,10 @@ actor CanonicalSyncService {
throw SyncError.cloudKitUnavailable throw SyncError.cloudKitUnavailable
} }
#if DEBUG
SyncStatusMonitor.shared.syncStarted()
#endif
// Mark sync in progress // Mark sync in progress
syncState.syncInProgress = true syncState.syncInProgress = true
syncState.lastSyncAttempt = Date() syncState.lastSyncAttempt = Date()
@@ -115,7 +121,7 @@ actor CanonicalSyncService {
var totalSports = 0 var totalSports = 0
var totalSkippedIncompatible = 0 var totalSkippedIncompatible = 0
var totalSkippedOlder = 0 var totalSkippedOlder = 0
var wasCancelled = false var nonCriticalErrors: [String] = []
/// Helper to save partial progress and check cancellation /// Helper to save partial progress and check cancellation
func saveProgressAndCheckCancellation() throws -> Bool { func saveProgressAndCheckCancellation() throws -> Bool {
@@ -129,41 +135,49 @@ actor CanonicalSyncService {
do { do {
// Sync in dependency order, checking cancellation between each entity type // Sync in dependency order, checking cancellation between each entity type
// Stadium sync // Sport sync (non-critical for schedule rendering)
var entityStartTime = Date() var entityStartTime = Date()
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums( do {
let (sports, skipIncompat1, skipOlder1) = try await syncSports(
context: context,
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalSports = sports
totalSkippedIncompatible += skipIncompat1
totalSkippedOlder += skipOlder1
syncState.lastSportSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("Sport: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Stadium sync
entityStartTime = Date()
let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums(
context: context, context: context,
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync, since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken cancellationToken: cancellationToken
) )
totalStadiums = stadiums totalStadiums = stadiums
totalSkippedIncompatible += skipIncompat1 totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder1 totalSkippedOlder += skipOlder2
syncState.lastStadiumSync = Date() syncState.lastStadiumSync = entityStartTime
#if DEBUG #if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime)) SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
#endif #endif
if try saveProgressAndCheckCancellation() { if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// League Structure sync
entityStartTime = Date()
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
context: context,
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalLeagueStructures = leagueStructures
totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder2
syncState.lastLeagueStructureSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError() throw CancellationError()
} }
@@ -177,92 +191,119 @@ actor CanonicalSyncService {
totalTeams = teams totalTeams = teams
totalSkippedIncompatible += skipIncompat3 totalSkippedIncompatible += skipIncompat3
totalSkippedOlder += skipOlder3 totalSkippedOlder += skipOlder3
syncState.lastTeamSync = Date() syncState.lastTeamSync = entityStartTime
#if DEBUG #if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime)) SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
#endif #endif
if try saveProgressAndCheckCancellation() { if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Team Alias sync
entityStartTime = Date()
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
context: context,
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalTeamAliases = teamAliases
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
syncState.lastTeamAliasSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Stadium Alias sync
entityStartTime = Date()
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
context: context,
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
syncState.lastStadiumAliasSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError() throw CancellationError()
} }
// Game sync // Game sync
entityStartTime = Date() entityStartTime = Date()
let (games, skipIncompat6, skipOlder6) = try await syncGames( let (games, skipIncompat4, skipOlder4) = try await syncGames(
context: context, context: context,
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync, since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken cancellationToken: cancellationToken
) )
totalGames = games totalGames = games
totalSkippedIncompatible += skipIncompat6 totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder6 totalSkippedOlder += skipOlder4
syncState.lastGameSync = Date() syncState.lastGameSync = entityStartTime
#if DEBUG #if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime)) SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
#endif #endif
if try saveProgressAndCheckCancellation() { if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError() throw CancellationError()
} }
// Sport sync // League Structure sync (non-critical for schedule rendering)
entityStartTime = Date() entityStartTime = Date()
let (sports, skipIncompat7, skipOlder7) = try await syncSports( do {
context: context, let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync, context: context,
cancellationToken: cancellationToken since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
) cancellationToken: cancellationToken
totalSports = sports )
totalSkippedIncompatible += skipIncompat7 totalLeagueStructures = leagueStructures
totalSkippedOlder += skipOlder7 totalSkippedIncompatible += skipIncompat5
syncState.lastSportSync = Date() totalSkippedOlder += skipOlder5
#if DEBUG syncState.lastLeagueStructureSync = entityStartTime
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime)) #if DEBUG
#endif SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Team Alias sync (non-critical for schedule rendering)
entityStartTime = Date()
do {
let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases(
context: context,
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalTeamAliases = teamAliases
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
syncState.lastTeamAliasSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Stadium Alias sync (non-critical for schedule rendering)
entityStartTime = Date()
do {
let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases(
context: context,
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat7
totalSkippedOlder += skipOlder7
syncState.lastStadiumAliasSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)")
}
// Mark sync successful - clear per-entity timestamps since full sync completed // Mark sync successful - clear per-entity timestamps since full sync completed
syncState.syncInProgress = false syncState.syncInProgress = false
syncState.lastSuccessfulSync = Date() syncState.lastSuccessfulSync = startTime
syncState.lastSyncError = nil syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
syncState.consecutiveFailures = 0 syncState.consecutiveFailures = 0
syncState.syncPausedReason = nil
// Clear per-entity timestamps - they're only needed for partial recovery // Clear per-entity timestamps - they're only needed for partial recovery
syncState.lastStadiumSync = nil syncState.lastStadiumSync = nil
syncState.lastTeamSync = nil syncState.lastTeamSync = nil
@@ -305,12 +346,24 @@ actor CanonicalSyncService {
// Mark sync failed // Mark sync failed
syncState.syncInProgress = false syncState.syncInProgress = false
syncState.lastSyncError = error.localizedDescription syncState.lastSyncError = error.localizedDescription
syncState.consecutiveFailures += 1
// Pause sync after too many failures if isTransientCloudKitError(error) {
if syncState.consecutiveFailures >= 5 { // Network/account hiccups should not permanently degrade sync health.
syncState.syncEnabled = false syncState.consecutiveFailures = 0
syncState.syncPausedReason = "Too many consecutive failures. Sync paused." syncState.syncPausedReason = nil
} else {
syncState.consecutiveFailures += 1
// Pause sync after too many failures
if syncState.consecutiveFailures >= 5 {
#if DEBUG
syncState.syncEnabled = false
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
#else
syncState.consecutiveFailures = 5
syncState.syncPausedReason = nil
#endif
}
} }
try? context.save() try? context.save()
@@ -347,6 +400,22 @@ actor CanonicalSyncService {
try? context.save() try? context.save()
} }
nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool {
guard let ckError = error as? CKError else { return false }
switch ckError.code {
case .networkUnavailable,
.networkFailure,
.serviceUnavailable,
.requestRateLimited,
.zoneBusy,
.notAuthenticated,
.accountTemporarilyUnavailable:
return true
default:
return false
}
}
// MARK: - Individual Sync Methods // MARK: - Individual Sync Methods
@MainActor @MainActor
@@ -388,6 +457,12 @@ actor CanonicalSyncService {
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Single call for all teams with delta sync // Single call for all teams with delta sync
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken) let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
let activeStadiums = try context.fetch(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
var updated = 0 var updated = 0
var skippedIncompatible = 0 var skippedIncompatible = 0
@@ -399,6 +474,7 @@ actor CanonicalSyncService {
syncTeam.team, syncTeam.team,
canonicalId: syncTeam.canonicalId, canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId, stadiumCanonicalId: syncTeam.stadiumCanonicalId,
validStadiumIds: &validStadiumIds,
context: context context: context
) )
@@ -409,6 +485,10 @@ actor CanonicalSyncService {
} }
} }
if skippedIncompatible > 0 {
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
}
return (updated, skippedIncompatible, skippedOlder) return (updated, skippedIncompatible, skippedOlder)
} }
@@ -420,6 +500,19 @@ actor CanonicalSyncService {
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Delta sync: nil = all games, Date = only modified since // Delta sync: nil = all games, Date = only modified since
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken) let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
let activeTeams = try context.fetch(
FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
let validTeamIds = Set(activeTeams.map(\.canonicalId))
let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
let activeStadiums = try context.fetch(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
var updated = 0 var updated = 0
var skippedIncompatible = 0 var skippedIncompatible = 0
@@ -433,6 +526,9 @@ actor CanonicalSyncService {
homeTeamCanonicalId: syncGame.homeTeamCanonicalId, homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
awayTeamCanonicalId: syncGame.awayTeamCanonicalId, awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
stadiumCanonicalId: syncGame.stadiumCanonicalId, stadiumCanonicalId: syncGame.stadiumCanonicalId,
validTeamIds: validTeamIds,
teamStadiumByTeamId: teamStadiumByTeamId,
validStadiumIds: &validStadiumIds,
context: context context: context
) )
@@ -443,6 +539,10 @@ actor CanonicalSyncService {
} }
} }
if skippedIncompatible > 0 {
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
}
return (updated, skippedIncompatible, skippedOlder) return (updated, skippedIncompatible, skippedOlder)
} }
@@ -585,6 +685,9 @@ actor CanonicalSyncService {
existing.timezoneIdentifier = remote.timeZoneIdentifier existing.timezoneIdentifier = remote.timeZoneIdentifier
existing.source = .cloudKit existing.source = .cloudKit
existing.lastModified = Date() existing.lastModified = Date()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.replacedByCanonicalId = nil
// Restore user fields // Restore user fields
existing.userNickname = savedNickname existing.userNickname = savedNickname
@@ -620,7 +723,8 @@ actor CanonicalSyncService {
private func mergeTeam( private func mergeTeam(
_ remote: Team, _ remote: Team,
canonicalId: String, canonicalId: String,
stadiumCanonicalId: String, stadiumCanonicalId: String?,
validStadiumIds: inout Set<String>,
context: ModelContext context: ModelContext
) throws -> MergeResult { ) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>( let descriptor = FetchDescriptor<CanonicalTeam>(
@@ -628,7 +732,32 @@ actor CanonicalSyncService {
) )
let existing = try context.fetch(descriptor).first let existing = try context.fetch(descriptor).first
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed! let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) {
resolvedStadiumCanonicalId = remoteStadiumId
} else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) {
resolvedStadiumCanonicalId = existingStadiumId
} else if let remoteStadiumId, !remoteStadiumId.isEmpty {
// Keep unresolved remote refs so teams/games can still sync while stadiums catch up.
resolvedStadiumCanonicalId = remoteStadiumId
} else if let existingStadiumId, !existingStadiumId.isEmpty {
resolvedStadiumCanonicalId = existingStadiumId
} else {
// Last-resort placeholder keeps team records usable for game rendering.
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
}
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
try ensurePlaceholderStadium(
canonicalId: resolvedStadiumCanonicalId,
sport: remote.sport,
context: context
)
validStadiumIds.insert(resolvedStadiumCanonicalId)
}
if let existing = existing { if let existing = existing {
// Preserve user fields // Preserve user fields
@@ -640,7 +769,7 @@ actor CanonicalSyncService {
existing.abbreviation = remote.abbreviation existing.abbreviation = remote.abbreviation
existing.sport = remote.sport.rawValue existing.sport = remote.sport.rawValue
existing.city = remote.city existing.city = remote.city
existing.stadiumCanonicalId = stadiumCanonicalId existing.stadiumCanonicalId = resolvedStadiumCanonicalId
existing.logoURL = remote.logoURL?.absoluteString existing.logoURL = remote.logoURL?.absoluteString
existing.primaryColor = remote.primaryColor existing.primaryColor = remote.primaryColor
existing.secondaryColor = remote.secondaryColor existing.secondaryColor = remote.secondaryColor
@@ -648,6 +777,9 @@ actor CanonicalSyncService {
existing.divisionId = remote.divisionId existing.divisionId = remote.divisionId
existing.source = .cloudKit existing.source = .cloudKit
existing.lastModified = Date() existing.lastModified = Date()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.relocatedToCanonicalId = nil
// Restore user fields // Restore user fields
existing.userNickname = savedNickname existing.userNickname = savedNickname
@@ -666,7 +798,7 @@ actor CanonicalSyncService {
abbreviation: remote.abbreviation, abbreviation: remote.abbreviation,
sport: remote.sport.rawValue, sport: remote.sport.rawValue,
city: remote.city, city: remote.city,
stadiumCanonicalId: stadiumCanonicalId, stadiumCanonicalId: resolvedStadiumCanonicalId,
logoURL: remote.logoURL?.absoluteString, logoURL: remote.logoURL?.absoluteString,
primaryColor: remote.primaryColor, primaryColor: remote.primaryColor,
secondaryColor: remote.secondaryColor, secondaryColor: remote.secondaryColor,
@@ -684,7 +816,10 @@ actor CanonicalSyncService {
canonicalId: String, canonicalId: String,
homeTeamCanonicalId: String, homeTeamCanonicalId: String,
awayTeamCanonicalId: String, awayTeamCanonicalId: String,
stadiumCanonicalId: String, stadiumCanonicalId: String?,
validTeamIds: Set<String>,
teamStadiumByTeamId: [String: String],
validStadiumIds: inout Set<String>,
context: ModelContext context: ModelContext
) throws -> MergeResult { ) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>( let descriptor = FetchDescriptor<CanonicalGame>(
@@ -692,7 +827,56 @@ actor CanonicalSyncService {
) )
let existing = try context.fetch(descriptor).first let existing = try context.fetch(descriptor).first
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed! func resolveTeamId(remote: String, existing: String?) -> String? {
if validTeamIds.contains(remote) {
return remote
}
if let existing, validTeamIds.contains(existing) {
return existing
}
return nil
}
guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId),
let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId)
else {
return .skippedIncompatible
}
let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackStadiumFromTeams = (
teamStadiumByTeamId[resolvedHomeTeamId] ??
teamStadiumByTeamId[resolvedAwayTeamId]
)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) {
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) {
// Cloud record can have stale/legacy stadium refs; prefer known team home venue.
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) {
// Keep existing local stadium if remote reference is invalid.
resolvedStadiumCanonicalId = trimmedExistingStadiumId
} else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty {
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty {
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty {
resolvedStadiumCanonicalId = trimmedExistingStadiumId
} else {
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
}
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
try ensurePlaceholderStadium(
canonicalId: resolvedStadiumCanonicalId,
sport: remote.sport,
context: context
)
validStadiumIds.insert(resolvedStadiumCanonicalId)
}
if let existing = existing { if let existing = existing {
// Preserve user fields // Preserve user fields
@@ -700,9 +884,9 @@ actor CanonicalSyncService {
let savedNotes = existing.userNotes let savedNotes = existing.userNotes
// Update system fields // Update system fields
existing.homeTeamCanonicalId = homeTeamCanonicalId existing.homeTeamCanonicalId = resolvedHomeTeamId
existing.awayTeamCanonicalId = awayTeamCanonicalId existing.awayTeamCanonicalId = resolvedAwayTeamId
existing.stadiumCanonicalId = stadiumCanonicalId existing.stadiumCanonicalId = resolvedStadiumCanonicalId
existing.dateTime = remote.dateTime existing.dateTime = remote.dateTime
existing.sport = remote.sport.rawValue existing.sport = remote.sport.rawValue
existing.season = remote.season existing.season = remote.season
@@ -710,6 +894,9 @@ actor CanonicalSyncService {
existing.broadcastInfo = remote.broadcastInfo existing.broadcastInfo = remote.broadcastInfo
existing.source = .cloudKit existing.source = .cloudKit
existing.lastModified = Date() existing.lastModified = Date()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.rescheduledToCanonicalId = nil
// Restore user fields // Restore user fields
existing.userAttending = savedAttending existing.userAttending = savedAttending
@@ -724,9 +911,9 @@ actor CanonicalSyncService {
schemaVersion: SchemaVersion.current, schemaVersion: SchemaVersion.current,
lastModified: Date(), lastModified: Date(),
source: .cloudKit, source: .cloudKit,
homeTeamCanonicalId: homeTeamCanonicalId, homeTeamCanonicalId: resolvedHomeTeamId,
awayTeamCanonicalId: awayTeamCanonicalId, awayTeamCanonicalId: resolvedAwayTeamId,
stadiumCanonicalId: stadiumCanonicalId, stadiumCanonicalId: resolvedStadiumCanonicalId,
dateTime: remote.dateTime, dateTime: remote.dateTime,
sport: remote.sport.rawValue, sport: remote.sport.rawValue,
season: remote.season, season: remote.season,
@@ -895,4 +1082,44 @@ actor CanonicalSyncService {
return .applied return .applied
} }
} }
@MainActor
private func ensurePlaceholderStadium(
canonicalId: String,
sport: Sport,
context: ModelContext
) throws {
let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCanonicalId.isEmpty else { return }
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == trimmedCanonicalId }
)
if let existing = try context.fetch(descriptor).first {
if existing.deprecatedAt != nil {
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.replacedByCanonicalId = nil
}
return
}
let placeholder = CanonicalStadium(
canonicalId: trimmedCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: "Venue TBD",
city: "Unknown",
state: "",
latitude: 0,
longitude: 0,
capacity: 0,
sport: sport.rawValue,
timezoneIdentifier: nil
)
context.insert(placeholder)
SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)")
}
} }

View File

@@ -62,14 +62,55 @@ enum CloudKitError: Error, LocalizedError {
actor CloudKitService { actor CloudKitService {
static let shared = CloudKitService() static let shared = CloudKitService()
nonisolated static let gameUpdatesSubscriptionID = "game-updates"
nonisolated static let teamUpdatesSubscriptionID = "team-updates"
nonisolated static let stadiumUpdatesSubscriptionID = "stadium-updates"
nonisolated static let leagueStructureUpdatesSubscriptionID = "league-structure-updates"
nonisolated static let teamAliasUpdatesSubscriptionID = "team-alias-updates"
nonisolated static let stadiumAliasUpdatesSubscriptionID = "stadium-alias-updates"
nonisolated static let sportUpdatesSubscriptionID = "sport-updates"
nonisolated static let canonicalSubscriptionIDs: Set<String> = [
gameUpdatesSubscriptionID,
teamUpdatesSubscriptionID,
stadiumUpdatesSubscriptionID,
leagueStructureUpdatesSubscriptionID,
teamAliasUpdatesSubscriptionID,
stadiumAliasUpdatesSubscriptionID,
sportUpdatesSubscriptionID
]
nonisolated static func recordType(forSubscriptionID subscriptionID: String) -> String? {
switch subscriptionID {
case gameUpdatesSubscriptionID:
return "Game"
case teamUpdatesSubscriptionID:
return "Team"
case stadiumUpdatesSubscriptionID:
return "Stadium"
case leagueStructureUpdatesSubscriptionID:
return "LeagueStructure"
case teamAliasUpdatesSubscriptionID:
return "TeamAlias"
case stadiumAliasUpdatesSubscriptionID:
return "StadiumAlias"
case sportUpdatesSubscriptionID:
return "Sport"
default:
return nil
}
}
private let container: CKContainer private let container: CKContainer
private let publicDatabase: CKDatabase private let publicDatabase: CKDatabase
/// Maximum records per CloudKit query (400 is the default limit) /// Maximum records per CloudKit query (400 is the default limit)
private let recordsPerPage = 400 private let recordsPerPage = 400
/// Re-fetch a small overlap window to avoid missing updates around sync boundaries.
private let deltaOverlapSeconds: TimeInterval = 120
private init() { private init() {
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") // Use target entitlements (debug/prod) instead of hardcoding a container ID.
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase self.publicDatabase = container.publicCloudDatabase
} }
@@ -83,6 +124,8 @@ actor CloudKitService {
) async throws -> [CKRecord] { ) async throws -> [CKRecord] {
var allRecords: [CKRecord] = [] var allRecords: [CKRecord] = []
var cursor: CKQueryOperation.Cursor? var cursor: CKQueryOperation.Cursor?
var partialFailureCount = 0
var firstPartialFailure: Error?
// First page // First page
let (firstResults, firstCursor) = try await publicDatabase.records( let (firstResults, firstCursor) = try await publicDatabase.records(
@@ -91,8 +134,14 @@ actor CloudKitService {
) )
for result in firstResults { for result in firstResults {
if case .success(let record) = result.1 { switch result.1 {
case .success(let record):
allRecords.append(record) allRecords.append(record)
case .failure(let error):
partialFailureCount += 1
if firstPartialFailure == nil {
firstPartialFailure = error
}
} }
} }
cursor = firstCursor cursor = firstCursor
@@ -110,16 +159,51 @@ actor CloudKitService {
) )
for result in results { for result in results {
if case .success(let record) = result.1 { switch result.1 {
case .success(let record):
allRecords.append(record) allRecords.append(record)
case .failure(let error):
partialFailureCount += 1
if firstPartialFailure == nil {
firstPartialFailure = error
}
} }
} }
cursor = nextCursor cursor = nextCursor
} }
if partialFailureCount > 0 {
SyncLogger.shared.log("⚠️ [CK] \(query.recordType) query had \(partialFailureCount) per-record failures")
if allRecords.isEmpty, let firstPartialFailure {
throw firstPartialFailure
}
}
return allRecords return allRecords
} }
/// Normalizes a stored sync timestamp into a safe CloudKit delta start.
/// - Returns: `nil` when a full sync should be used.
private func effectiveDeltaStartDate(_ lastSync: Date?) -> Date? {
guard let lastSync else { return nil }
let now = Date()
if lastSync > now.addingTimeInterval(60) {
SyncLogger.shared.log("⚠️ [CK] Last sync timestamp is in the future; falling back to full sync")
return nil
}
return lastSync.addingTimeInterval(-deltaOverlapSeconds)
}
/// Fails fast when CloudKit returned records but none were parseable.
private func throwIfAllDropped(recordType: String, totalRecords: Int, parsedRecords: Int) throws {
guard totalRecords > 0, parsedRecords == 0 else { return }
let message = "All \(recordType) records were unparseable (\(totalRecords) fetched)"
SyncLogger.shared.log("❌ [CK] \(message)")
throw CloudKitError.serverError(message)
}
// MARK: - Sync Types (include canonical IDs from CloudKit) // MARK: - Sync Types (include canonical IDs from CloudKit)
struct SyncStadium { struct SyncStadium {
@@ -130,7 +214,7 @@ actor CloudKitService {
struct SyncTeam { struct SyncTeam {
let team: Team let team: Team
let canonicalId: String let canonicalId: String
let stadiumCanonicalId: String let stadiumCanonicalId: String?
} }
struct SyncGame { struct SyncGame {
@@ -138,23 +222,29 @@ actor CloudKitService {
let canonicalId: String let canonicalId: String
let homeTeamCanonicalId: String let homeTeamCanonicalId: String
let awayTeamCanonicalId: String let awayTeamCanonicalId: String
let stadiumCanonicalId: String let stadiumCanonicalId: String?
} }
// MARK: - Availability Check // MARK: - Availability Check
func isAvailable() async -> Bool { func isAvailable() async -> Bool {
let status = await checkAccountStatus() let status = await checkAccountStatus()
return status == .available switch status {
case .available, .noAccount, .couldNotDetermine:
// Public DB reads should still be attempted without an iCloud account.
return true
case .restricted, .temporarilyUnavailable:
return false
@unknown default:
return false
}
} }
func checkAvailabilityWithError() async throws { func checkAvailabilityWithError() async throws {
let status = await checkAccountStatus() let status = await checkAccountStatus()
switch status { switch status {
case .available: case .available, .noAccount:
return return
case .noAccount:
throw CloudKitError.notSignedIn
case .restricted: case .restricted:
throw CloudKitError.permissionDenied throw CloudKitError.permissionDenied
case .couldNotDetermine: case .couldNotDetermine:
@@ -268,23 +358,39 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. /// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] { func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) log.log("☁️ [CK] Fetching stadiums modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL stadiums (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) stadium records from CloudKit")
return records.compactMap { record -> SyncStadium? in var validStadiums: [SyncStadium] = []
var skipped = 0
for record in records {
let ckStadium = CKStadium(record: record) let ckStadium = CKStadium(record: record)
guard let stadium = ckStadium.stadium, guard let stadium = ckStadium.stadium,
let canonicalId = ckStadium.canonicalId let canonicalId = ckStadium.canonicalId
else { return nil } else {
return SyncStadium(stadium: stadium, canonicalId: canonicalId) skipped += 1
continue
}
validStadiums.append(SyncStadium(stadium: stadium, canonicalId: canonicalId))
} }
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) stadium records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.stadium, totalRecords: records.count, parsedRecords: validStadiums.count)
return validStadiums
} }
/// Fetch teams for sync operations /// Fetch teams for sync operations
@@ -292,24 +398,47 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date. /// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] { func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) log.log("☁️ [CK] Fetching teams modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL teams (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) team records from CloudKit")
return records.compactMap { record -> SyncTeam? in var validTeams: [SyncTeam] = []
var skipped = 0
var missingStadiumRef = 0
for record in records {
let ckTeam = CKTeam(record: record) let ckTeam = CKTeam(record: record)
guard let team = ckTeam.team, guard let team = ckTeam.team,
let canonicalId = ckTeam.canonicalId, let canonicalId = ckTeam.canonicalId
let stadiumCanonicalId = ckTeam.stadiumCanonicalId else {
else { return nil } skipped += 1
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId) continue
}
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
if stadiumCanonicalId == nil {
missingStadiumRef += 1
}
validTeams.append(SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId))
} }
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) team records due to missing required fields")
}
if missingStadiumRef > 0 {
log.log("⚠️ [CK] \(missingStadiumRef) team records are missing stadium refs; merge will preserve local stadiums when possible")
}
try throwIfAllDropped(recordType: CKRecordType.team, totalRecords: records.count, parsedRecords: validTeams.count)
return validTeams
} }
/// Fetch games for sync operations /// Fetch games for sync operations
@@ -319,9 +448,9 @@ actor CloudKitService {
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] { func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
let log = SyncLogger.shared let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())") log.log("☁️ [CK] Fetching games modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL games (full sync)") log.log("☁️ [CK] Fetching ALL games (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
@@ -334,23 +463,28 @@ actor CloudKitService {
var validGames: [SyncGame] = [] var validGames: [SyncGame] = []
var skippedMissingIds = 0 var skippedMissingIds = 0
var skippedInvalidGame = 0 var skippedInvalidGame = 0
var missingStadiumRef = 0
for record in records { for record in records {
let ckGame = CKGame(record: record) let ckGame = CKGame(record: record)
guard let canonicalId = ckGame.canonicalId, guard let canonicalId = ckGame.canonicalId,
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, let awayTeamCanonicalId = ckGame.awayTeamCanonicalId
let stadiumCanonicalId = ckGame.stadiumCanonicalId
else { else {
skippedMissingIds += 1 skippedMissingIds += 1
continue continue
} }
let stadiumCanonicalId = ckGame.stadiumCanonicalId
if stadiumCanonicalId == nil {
missingStadiumRef += 1
}
guard let game = ckGame.game( guard let game = ckGame.game(
homeTeamId: homeTeamCanonicalId, homeTeamId: homeTeamCanonicalId,
awayTeamId: awayTeamCanonicalId, awayTeamId: awayTeamCanonicalId,
stadiumId: stadiumCanonicalId stadiumId: stadiumCanonicalId ?? "stadium_placeholder_\(canonicalId)"
) else { ) else {
skippedInvalidGame += 1 skippedInvalidGame += 1
continue continue
@@ -366,6 +500,10 @@ actor CloudKitService {
} }
log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)") log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)")
if missingStadiumRef > 0 {
log.log("⚠️ [CK] \(missingStadiumRef) games are missing stadium refs; merge will derive stadiums from team mappings when possible")
}
try throwIfAllDropped(recordType: CKRecordType.game, totalRecords: records.count, parsedRecords: validGames.count)
// Log sport breakdown // Log sport breakdown
var bySport: [String: Int] = [:] var bySport: [String: Int] = [:]
@@ -443,20 +581,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] { func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) log.log("☁️ [CK] Fetching league structures modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL league structures (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
return records.compactMap { record in var parsed: [LeagueStructureModel] = []
return CKLeagueStructure(record: record).toModel() var skipped = 0
for record in records {
if let model = CKLeagueStructure(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) league structure records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.leagueStructure, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
} }
} }
@@ -465,20 +620,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] { func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) log.log("☁️ [CK] Fetching team aliases modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL team aliases (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
return records.compactMap { record in var parsed: [TeamAlias] = []
return CKTeamAlias(record: record).toModel() var skipped = 0
for record in records {
if let model = CKTeamAlias(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) team alias records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.teamAlias, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
} }
} }
@@ -487,20 +659,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] { func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) log.log("☁️ [CK] Fetching stadium aliases modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL stadium aliases (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
return records.compactMap { record in var parsed: [StadiumAlias] = []
return CKStadiumAlias(record: record).toModel() var skipped = 0
for record in records {
if let model = CKStadiumAlias(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) stadium alias records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.stadiumAlias, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
} }
} }
@@ -511,19 +700,36 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date. /// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages /// - cancellationToken: Optional token to check for cancellation between pages
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] { func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
let log = SyncLogger.shared
let predicate: NSPredicate let predicate: NSPredicate
if let lastSync = lastSync { if let deltaStart = effectiveDeltaStartDate(lastSync) {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) log.log("☁️ [CK] Fetching sports modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else { } else {
log.log("☁️ [CK] Fetching ALL sports (full sync)")
predicate = NSPredicate(value: true) predicate = NSPredicate(value: true)
} }
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate) let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
return records.compactMap { record -> CanonicalSport? in var parsed: [CanonicalSport] = []
return CKSport(record: record).toCanonical() var skipped = 0
for record in records {
if let sport = CKSport(record: record).toCanonical() {
parsed.append(sport)
} else {
skipped += 1
}
} }
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) sport records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.sport, totalRecords: records.count, parsedRecords: parsed.count)
return parsed
} }
// MARK: - Sync Status // MARK: - Sync Status
@@ -542,83 +748,130 @@ actor CloudKitService {
let subscription = CKQuerySubscription( let subscription = CKQuerySubscription(
recordType: CKRecordType.game, recordType: CKRecordType.game,
predicate: NSPredicate(value: true), predicate: NSPredicate(value: true),
subscriptionID: "game-updates", subscriptionID: Self.gameUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate] options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
) )
let notification = CKSubscription.NotificationInfo() let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification subscription.notificationInfo = notification
try await publicDatabase.save(subscription) try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToTeamUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.team,
predicate: NSPredicate(value: true),
subscriptionID: Self.teamUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToStadiumUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadium,
predicate: NSPredicate(value: true),
subscriptionID: Self.stadiumUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await saveSubscriptionIfNeeded(subscription)
} }
func subscribeToLeagueStructureUpdates() async throws { func subscribeToLeagueStructureUpdates() async throws {
let subscription = CKQuerySubscription( let subscription = CKQuerySubscription(
recordType: CKRecordType.leagueStructure, recordType: CKRecordType.leagueStructure,
predicate: NSPredicate(value: true), predicate: NSPredicate(value: true),
subscriptionID: "league-structure-updates", subscriptionID: Self.leagueStructureUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate] options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
) )
let notification = CKSubscription.NotificationInfo() let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification subscription.notificationInfo = notification
try await publicDatabase.save(subscription) try await saveSubscriptionIfNeeded(subscription)
} }
func subscribeToTeamAliasUpdates() async throws { func subscribeToTeamAliasUpdates() async throws {
let subscription = CKQuerySubscription( let subscription = CKQuerySubscription(
recordType: CKRecordType.teamAlias, recordType: CKRecordType.teamAlias,
predicate: NSPredicate(value: true), predicate: NSPredicate(value: true),
subscriptionID: "team-alias-updates", subscriptionID: Self.teamAliasUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate] options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
) )
let notification = CKSubscription.NotificationInfo() let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification subscription.notificationInfo = notification
try await publicDatabase.save(subscription) try await saveSubscriptionIfNeeded(subscription)
} }
func subscribeToStadiumAliasUpdates() async throws { func subscribeToStadiumAliasUpdates() async throws {
let subscription = CKQuerySubscription( let subscription = CKQuerySubscription(
recordType: CKRecordType.stadiumAlias, recordType: CKRecordType.stadiumAlias,
predicate: NSPredicate(value: true), predicate: NSPredicate(value: true),
subscriptionID: "stadium-alias-updates", subscriptionID: Self.stadiumAliasUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate] options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
) )
let notification = CKSubscription.NotificationInfo() let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification subscription.notificationInfo = notification
try await publicDatabase.save(subscription) try await saveSubscriptionIfNeeded(subscription)
} }
func subscribeToSportUpdates() async throws { func subscribeToSportUpdates() async throws {
let subscription = CKQuerySubscription( let subscription = CKQuerySubscription(
recordType: CKRecordType.sport, recordType: CKRecordType.sport,
predicate: NSPredicate(value: true), predicate: NSPredicate(value: true),
subscriptionID: "sport-updates", subscriptionID: Self.sportUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate] options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
) )
let notification = CKSubscription.NotificationInfo() let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification subscription.notificationInfo = notification
try await publicDatabase.save(subscription) try await saveSubscriptionIfNeeded(subscription)
} }
/// Subscribe to all canonical data updates /// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws { func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates() try await subscribeToScheduleUpdates()
try await subscribeToTeamUpdates()
try await subscribeToStadiumUpdates()
try await subscribeToLeagueStructureUpdates() try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates() try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates() try await subscribeToStadiumAliasUpdates()
try await subscribeToSportUpdates() try await subscribeToSportUpdates()
} }
private func saveSubscriptionIfNeeded(_ subscription: CKQuerySubscription) async throws {
do {
try await publicDatabase.save(subscription)
} catch let error as CKError where error.code == .serverRejectedRequest {
// Existing subscriptions can be rejected as duplicates in some environments.
// Confirm it exists before treating this as non-fatal.
do {
_ = try await publicDatabase.subscription(for: subscription.subscriptionID)
SyncLogger.shared.log(" [CK] Subscription already exists: \(subscription.subscriptionID)")
} catch {
throw error
}
}
}
} }

View File

@@ -221,22 +221,26 @@ final class AppDataProvider: ObservableObject {
var richGames: [RichGame] = [] var richGames: [RichGame] = []
var droppedGames: [(game: Game, reason: String)] = [] var droppedGames: [(game: Game, reason: String)] = []
var stadiumFallbacksApplied = 0
for game in games { for game in games {
let homeTeam = teamsById[game.homeTeamId] let homeTeam = teamsById[game.homeTeamId]
let awayTeam = teamsById[game.awayTeamId] let awayTeam = teamsById[game.awayTeamId]
let stadium = stadiumsById[game.stadiumId] let resolvedStadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam)
if resolvedStadium?.id != game.stadiumId {
stadiumFallbacksApplied += 1
}
if homeTeam == nil || awayTeam == nil || stadium == nil { if homeTeam == nil || awayTeam == nil || resolvedStadium == nil {
var reasons: [String] = [] var reasons: [String] = []
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") } if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") } if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") }
if stadium == nil { reasons.append("stadium(\(game.stadiumId))") } if resolvedStadium == nil { reasons.append("stadium(\(game.stadiumId))") }
droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))")) droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))"))
continue continue
} }
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: stadium!)) richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!))
} }
if !droppedGames.isEmpty { if !droppedGames.isEmpty {
@@ -248,6 +252,9 @@ final class AppDataProvider: ObservableObject {
print("⚠️ [DATA] ... and \(droppedGames.count - 10) more") print("⚠️ [DATA] ... and \(droppedGames.count - 10) more")
} }
} }
if stadiumFallbacksApplied > 0 {
print("⚠️ [DATA] Applied stadium fallback for \(stadiumFallbacksApplied) games")
}
print("🎮 [DATA] Returning \(richGames.count) rich games") print("🎮 [DATA] Returning \(richGames.count) rich games")
return richGames return richGames
@@ -260,7 +267,7 @@ final class AppDataProvider: ObservableObject {
return games.compactMap { game in return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId], guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId], let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else { let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil return nil
} }
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
@@ -270,7 +277,7 @@ final class AppDataProvider: ObservableObject {
func richGame(from game: Game) -> RichGame? { func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId], guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId], let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else { let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil return nil
} }
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
@@ -299,13 +306,27 @@ final class AppDataProvider: ObservableObject {
let game = canonical.toDomain() let game = canonical.toDomain()
guard let homeTeam = teamsById[game.homeTeamId], guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId], let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else { let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
continue continue
} }
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)) teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
} }
return teamGames return teamGames
} }
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.
private func resolveStadium(for game: Game, homeTeam: Team?, awayTeam: Team?) -> Stadium? {
if let stadium = stadiumsById[game.stadiumId] {
return stadium
}
if let homeTeam, let fallback = stadiumsById[homeTeam.stadiumId] {
return fallback
}
if let awayTeam, let fallback = stadiumsById[awayTeam.stadiumId] {
return fallback
}
return nil
}
} }
// MARK: - Errors // MARK: - Errors

View File

@@ -5,7 +5,7 @@ import CloudKit
actor ItineraryItemService { actor ItineraryItemService {
static let shared = ItineraryItemService() static let shared = ItineraryItemService()
private let container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") private let container = CKContainer.default()
private var database: CKDatabase { container.privateCloudDatabase } private var database: CKDatabase { container.privateCloudDatabase }
private let recordType = "ItineraryItem" private let recordType = "ItineraryItem"

View File

@@ -52,7 +52,8 @@ actor PollService {
private var pollSubscriptionID: CKSubscription.ID? private var pollSubscriptionID: CKSubscription.ID?
private init() { private init() {
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") // Respect target entitlements so Debug and production stay isolated.
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase self.publicDatabase = container.publicCloudDatabase
} }

View File

@@ -10,14 +10,14 @@ import os
/// Protocol for cancellation tokens checked between sync pages /// Protocol for cancellation tokens checked between sync pages
protocol SyncCancellationToken: Sendable { protocol SyncCancellationToken: Sendable {
var isCancelled: Bool { get } nonisolated var isCancelled: Bool { get }
} }
/// Concrete cancellation token for background tasks /// Concrete cancellation token for background tasks
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable { final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(initialState: false) private let lock = OSAllocatedUnfairLock(initialState: false)
var isCancelled: Bool { nonisolated var isCancelled: Bool {
lock.withLock { $0 } lock.withLock { $0 }
} }

View File

@@ -8,7 +8,7 @@
import Foundation import Foundation
final class SyncLogger { nonisolated final class SyncLogger {
static let shared = SyncLogger() static let shared = SyncLogger()
private let fileURL: URL private let fileURL: URL

View File

@@ -63,7 +63,7 @@ final class VisitPhotoService {
init(modelContext: ModelContext) { init(modelContext: ModelContext) {
self.modelContext = modelContext self.modelContext = modelContext
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") self.container = CKContainer.default()
self.privateDatabase = container.privateCloudDatabase self.privateDatabase = container.privateCloudDatabase
} }

View File

@@ -12,9 +12,11 @@ struct SettingsView: View {
@State private var showResetConfirmation = false @State private var showResetConfirmation = false
@State private var showPaywall = false @State private var showPaywall = false
@State private var showOnboardingPaywall = false @State private var showOnboardingPaywall = false
@State private var showSyncLogs = false
@State private var isSyncActionInProgress = false
@State private var syncActionMessage: String?
#if DEBUG #if DEBUG
@State private var selectedSyncStatus: EntitySyncStatus? @State private var selectedSyncStatus: EntitySyncStatus?
@State private var showSyncLogs = false
@State private var exporter = DebugShareExporter() @State private var exporter = DebugShareExporter()
@State private var showExportProgress = false @State private var showExportProgress = false
#endif #endif
@@ -39,6 +41,9 @@ struct SettingsView: View {
// Travel Preferences // Travel Preferences
travelSection travelSection
// Data Sync
syncHealthSection
// Privacy // Privacy
privacySection privacySection
@@ -72,6 +77,17 @@ struct SettingsView: View {
.sheet(isPresented: $showOnboardingPaywall) { .sheet(isPresented: $showOnboardingPaywall) {
OnboardingPaywallView(isPresented: $showOnboardingPaywall) OnboardingPaywallView(isPresented: $showOnboardingPaywall)
} }
.sheet(isPresented: $showSyncLogs) {
SyncLogViewerSheet()
}
.alert("Sync Status", isPresented: Binding(
get: { syncActionMessage != nil },
set: { if !$0 { syncActionMessage = nil } }
)) {
Button("OK", role: .cancel) { syncActionMessage = nil }
} message: {
Text(syncActionMessage ?? "")
}
} }
// MARK: - Appearance Section // MARK: - Appearance Section
@@ -370,6 +386,97 @@ struct SettingsView: View {
.listRowBackground(Theme.cardBackground(colorScheme)) .listRowBackground(Theme.cardBackground(colorScheme))
} }
// MARK: - Sync Health Section
private var syncHealthSection: some View {
Section {
let syncState = SyncState.current(in: modelContext)
if syncState.syncInProgress || isSyncActionInProgress {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Sync in progress...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let lastSync = syncState.lastSuccessfulSync {
HStack {
Label("Last Successful Sync", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Spacer()
Text(lastSync.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
HStack {
Label("Last Successful Sync", systemImage: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
Spacer()
Text("Never")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let lastError = syncState.lastSyncError, !lastError.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Last Sync Warning", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.subheadline)
Text(lastError)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !syncState.syncEnabled {
VStack(alignment: .leading, spacing: 4) {
Label("Sync Paused", systemImage: "pause.circle.fill")
.foregroundStyle(.orange)
.font(.subheadline)
if let reason = syncState.syncPausedReason {
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button {
Task {
let syncService = CanonicalSyncService()
await syncService.resumeSync(context: modelContext)
syncActionMessage = "Sync has been re-enabled."
}
} label: {
Label("Re-enable Sync", systemImage: "play.circle")
}
.disabled(isSyncActionInProgress)
}
Button {
triggerManualSync()
} label: {
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(isSyncActionInProgress)
Button {
showSyncLogs = true
} label: {
Label("View Sync Logs", systemImage: "doc.text.magnifyingglass")
}
} header: {
Text("Data Sync")
} footer: {
Text("SportsTime loads bundled data first, then refreshes from CloudKit.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Debug Section // MARK: - Debug Section
#if DEBUG #if DEBUG
@@ -548,9 +655,6 @@ struct SettingsView: View {
.sheet(item: $selectedSyncStatus) { status in .sheet(item: $selectedSyncStatus) { status in
SyncStatusDetailSheet(status: status) SyncStatusDetailSheet(status: status)
} }
.sheet(isPresented: $showSyncLogs) {
SyncLogViewerSheet()
}
} }
#endif #endif
@@ -633,6 +737,23 @@ struct SettingsView: View {
// MARK: - Helpers // MARK: - Helpers
private func triggerManualSync() {
guard !isSyncActionInProgress else { return }
isSyncActionInProgress = true
Task {
defer { isSyncActionInProgress = false }
do {
let result = try await BackgroundSyncManager.shared.triggerManualSync()
syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records."
} catch {
syncActionMessage = "Sync failed: \(error.localizedDescription)"
}
}
}
private func sportColor(for sport: Sport) -> Color { private func sportColor(for sport: Sport) -> Color {
sport.themeColor sport.themeColor
} }
@@ -775,6 +896,8 @@ private struct DetailRow: View {
} }
} }
#endif
/// Sheet to view sync logs /// Sheet to view sync logs
struct SyncLogViewerSheet: View { struct SyncLogViewerSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -837,5 +960,3 @@ struct SyncLogViewerSheet: View {
} }
} }
} }
#endif

View File

@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>production</string>
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array> <array>
<string>iCloud.com.88oakapps.SportsTime</string> <string>iCloud.com.88oakapps.SportsTime</string>

View File

@@ -18,6 +18,9 @@ struct SportsTimeApp: App {
private var transactionListener: Task<Void, Never>? private var transactionListener: Task<Void, Never>?
init() { init() {
// Configure sync manager immediately so push/background triggers can sync.
BackgroundSyncManager.shared.configure(with: sharedModelContainer)
// Register background tasks BEFORE app finishes launching // Register background tasks BEFORE app finishes launching
// This must happen synchronously in init or applicationDidFinishLaunching // This must happen synchronously in init or applicationDidFinishLaunching
BackgroundSyncManager.shared.registerTasks() BackgroundSyncManager.shared.registerTasks()
@@ -181,6 +184,9 @@ struct BootstrappedContentView: View {
// 4. Load data from SwiftData into memory // 4. Load data from SwiftData into memory
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...") print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
await AppDataProvider.shared.loadInitialData() await AppDataProvider.shared.loadInitialData()
if let loadError = AppDataProvider.shared.error {
throw loadError
}
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams") print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams")
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums") print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
@@ -207,10 +213,15 @@ struct BootstrappedContentView: View {
// 9. Schedule background tasks for future syncs // 9. Schedule background tasks for future syncs
BackgroundSyncManager.shared.scheduleAllTasks() BackgroundSyncManager.shared.scheduleAllTasks()
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
Task(priority: .utility) {
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
}
// 10. Background: Try to refresh from CloudKit (non-blocking) // 10. Background: Try to refresh from CloudKit (non-blocking)
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...") print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
Task.detached(priority: .background) { Task(priority: .background) {
await self.performBackgroundSync(context: context) await self.performBackgroundSync(context: self.modelContainer.mainContext)
await MainActor.run { await MainActor.run {
self.hasCompletedInitialSync = true self.hasCompletedInitialSync = true
} }
@@ -227,9 +238,16 @@ struct BootstrappedContentView: View {
let log = SyncLogger.shared let log = SyncLogger.shared
log.log("🔄 [SYNC] Starting background sync...") log.log("🔄 [SYNC] Starting background sync...")
// Reset stale syncInProgress flag (in case app was killed mid-sync) // Only reset stale syncInProgress flags; do not clobber an actively running sync.
let syncState = SyncState.current(in: context) let syncState = SyncState.current(in: context)
if syncState.syncInProgress { if syncState.syncInProgress {
let staleSyncTimeout: TimeInterval = 15 * 60
if let lastAttempt = syncState.lastSyncAttempt,
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
log.log(" [SYNC] Sync already in progress; skipping duplicate trigger")
return
}
log.log("⚠️ [SYNC] Resetting stale syncInProgress flag") log.log("⚠️ [SYNC] Resetting stale syncInProgress flag")
syncState.syncInProgress = false syncState.syncInProgress = false
try? context.save() try? context.save()

View 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
View 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**

View 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
View 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 |

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB