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