From 2917ae22b1bb3f28bde69a202e74ba3613b3f8a0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 10 Feb 2026 15:12:16 -0600 Subject: [PATCH] feat: add PostHog analytics with full event tracking across app Integrate self-hosted PostHog (SPM) with AnalyticsManager singleton wrapping all SDK calls. Adds ~40 type-safe events covering trip planning, schedule, progress, IAP, settings, polls, export, and share flows. Includes session replay, autocapture, network telemetry, privacy opt-out toggle in Settings, and super properties (app version, device, pro status, selected sports). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 40 +++ SportsTime.xcodeproj/project.pbxproj | 17 + .../Core/Analytics/AnalyticsEvent.swift | 258 +++++++++++++++ .../Core/Analytics/AnalyticsManager.swift | 300 ++++++++++++++++++ SportsTime/Core/Store/StoreManager.swift | 222 ++++++++++++- SportsTime/Export/Views/ShareButton.swift | 5 + SportsTime/Features/Home/Views/HomeView.swift | 16 +- .../Paywall/ViewModifiers/ProGate.swift | 4 +- .../Paywall/Views/OnboardingPaywallView.swift | 6 +- .../Features/Paywall/Views/PaywallView.swift | 29 +- .../ViewModels/PollCreationViewModel.swift | 1 + .../ViewModels/PollVotingViewModel.swift | 1 + .../ViewModels/ProgressViewModel.swift | 5 + .../Progress/Views/StadiumVisitSheet.swift | 1 + .../ViewModels/ScheduleViewModel.swift | 2 + .../ViewModels/SettingsViewModel.swift | 10 +- .../Settings/Views/SettingsView.swift | 49 ++- .../Features/Trip/Views/TripDetailView.swift | 13 +- .../Trip/Views/Wizard/TripWizardView.swift | 13 + SportsTime/SportsTimeApp.swift | 20 +- 20 files changed, 989 insertions(+), 23 deletions(-) create mode 100644 SportsTime/Core/Analytics/AnalyticsEvent.swift create mode 100644 SportsTime/Core/Analytics/AnalyticsManager.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3ae4fba..a724730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,46 @@ TripCreationView → TripCreationViewModel → PlanningRequest - CloudKit container ID: `iCloud.com.88oakapps.SportsTime` - `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing +### Analytics (PostHog) + +All analytics go through `AnalyticsManager.shared` — never call PostHog SDK directly. + +**Architecture** (`Core/Analytics/`): +- `AnalyticsManager.swift` - `@MainActor` singleton wrapping PostHogSDK. Handles init, tracking, opt-in/out, super properties, session replay. +- `AnalyticsEvent.swift` - Type-safe enum with ~40 event cases, each with `name: String` and `properties: [String: Any]`. + +**Self-hosted backend:** `https://analytics.88oakapps.com` +**API key:** Set in `AnalyticsManager.apiKey` (replace placeholder before shipping) + +**Features enabled:** +- Event capture + autocapture +- Session replay (`screenshotMode` for SwiftUI, text inputs masked) +- Network telemetry capture +- Super properties (app version, device model, OS, pro status, selected sports) +- Privacy opt-out toggle in Settings (persisted via UserDefaults `"analyticsOptedOut"`) + +**Adding new analytics:** +```swift +// 1. Add case to AnalyticsEvent enum +case myNewEvent(param: String) + +// 2. Add name and properties in the computed properties +// 3. Call from anywhere: +AnalyticsManager.shared.track(.myNewEvent(param: "value")) +``` + +**Correct Usage:** +```swift +// ✅ CORRECT - Use AnalyticsManager +AnalyticsManager.shared.track(.tripSaved(tripId: id, stopCount: 3, gameCount: 5)) +AnalyticsManager.shared.trackScreen("TripDetail") + +// ❌ WRONG - Never call PostHog SDK directly +PostHogSDK.shared.capture("trip_saved") // NO! +``` + +**Initialization:** Called during app bootstrap in `SportsTimeApp.performBootstrap()` (Step 7). Super properties refreshed on `.active` scene phase. Flushed on `.background`. + ### Themed Background System All views use `.themedBackground()` modifier for consistent backgrounds app-wide. diff --git a/SportsTime.xcodeproj/project.pbxproj b/SportsTime.xcodeproj/project.pbxproj index 07da0ad..62f77d9 100644 --- a/SportsTime.xcodeproj/project.pbxproj +++ b/SportsTime.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E32F148BA4007D870A /* SwiftSoup */; }; + 1CC750E72F15A1B0007D870A /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E62F15A1B0007D870A /* PostHog */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,6 +71,7 @@ buildActionMask = 2147483647; files = ( 1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */, + 1CC750E72F15A1B0007D870A /* PostHog in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -139,6 +141,7 @@ name = SportsTime; packageProductDependencies = ( 1CC750E32F148BA4007D870A /* SwiftSoup */, + 1CC750E62F15A1B0007D870A /* PostHog */, ); productName = SportsTime; productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */; @@ -224,6 +227,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */, + 1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */, ); preferredProjectObjectVersion = 77; productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */; @@ -626,6 +630,14 @@ minimumVersion = 2.11.3; }; }; + 1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/PostHog/posthog-ios.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -634,6 +646,11 @@ package = 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; + 1CC750E62F15A1B0007D870A /* PostHog */ = { + isa = XCSwiftPackageProductDependency; + package = 1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */; + productName = PostHog; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */; diff --git a/SportsTime/Core/Analytics/AnalyticsEvent.swift b/SportsTime/Core/Analytics/AnalyticsEvent.swift new file mode 100644 index 0000000..8c5d6fa --- /dev/null +++ b/SportsTime/Core/Analytics/AnalyticsEvent.swift @@ -0,0 +1,258 @@ +// +// AnalyticsEvent.swift +// SportsTime +// +// Type-safe analytics event definitions. +// + +import Foundation + +enum AnalyticsEvent { + + // MARK: - Navigation + + case tabSwitched(tab: String, previousTab: String?) + case screenViewed(screen: String) + + // MARK: - Trip Planning + + case tripWizardStarted(mode: String) + case tripWizardStepCompleted(step: String, mode: String) + case tripPlanned(sportCount: Int, stopCount: Int, dayCount: Int, mode: String) + case tripPlanFailed(mode: String, error: String) + case tripSaved(tripId: String, stopCount: Int, gameCount: Int) + case tripDeleted(tripId: String) + case tripViewed(tripId: String, source: String) + case suggestedTripTapped(region: String, stopCount: Int) + + // MARK: - Schedule + + case scheduleViewed(sports: [String]) + case scheduleFiltered(sport: String, dateRange: String) + case gameTapped(gameId: String, sport: String, homeTeam: String, awayTeam: String) + + // MARK: - Progress + + case stadiumVisitAdded(stadiumId: String, sport: String) + case stadiumVisitDeleted(stadiumId: String, sport: String) + case progressCardShared(sport: String) + case sportSwitched(sport: String) + + // MARK: - Export + + case pdfExportStarted(tripId: String, stopCount: Int) + case pdfExportCompleted(tripId: String) + case pdfExportFailed(tripId: String, error: String) + case tripShared(tripId: String) + + // MARK: - IAP + + case paywallViewed(source: String) + case purchaseStarted(productId: String) + case purchaseCompleted(productId: String) + case purchaseFailed(productId: String, error: String) + case purchaseRestored + case subscriptionStatusChanged(isPro: Bool, plan: String?) + + // MARK: - Settings + + case themeChanged(from: String, to: String) + case appearanceChanged(mode: String) + case sportToggled(sport: String, enabled: Bool) + case animationsToggled(enabled: Bool) + case drivingHoursChanged(hours: Int) + case analyticsToggled(enabled: Bool) + case settingsReset + + // MARK: - Polls + + case pollCreated(optionCount: Int) + case pollVoted(pollId: String) + case pollShared(pollId: String) + + // MARK: - Onboarding + + case onboardingPaywallViewed + case onboardingPaywallDismissed + + // MARK: - Errors + + case errorOccurred(domain: String, message: String, screen: String?) + + // MARK: - Computed Properties + + var name: String { + switch self { + case .tabSwitched: return "tab_switched" + case .screenViewed: return "screen_viewed" + case .tripWizardStarted: return "trip_wizard_started" + case .tripWizardStepCompleted: return "trip_wizard_step_completed" + case .tripPlanned: return "trip_planned" + case .tripPlanFailed: return "trip_plan_failed" + case .tripSaved: return "trip_saved" + case .tripDeleted: return "trip_deleted" + case .tripViewed: return "trip_viewed" + case .suggestedTripTapped: return "suggested_trip_tapped" + case .scheduleViewed: return "schedule_viewed" + case .scheduleFiltered: return "schedule_filtered" + case .gameTapped: return "game_tapped" + case .stadiumVisitAdded: return "stadium_visit_added" + case .stadiumVisitDeleted: return "stadium_visit_deleted" + case .progressCardShared: return "progress_card_shared" + case .sportSwitched: return "sport_switched" + case .pdfExportStarted: return "pdf_export_started" + case .pdfExportCompleted: return "pdf_export_completed" + case .pdfExportFailed: return "pdf_export_failed" + case .tripShared: return "trip_shared" + case .paywallViewed: return "paywall_viewed" + case .purchaseStarted: return "purchase_started" + case .purchaseCompleted: return "purchase_completed" + case .purchaseFailed: return "purchase_failed" + case .purchaseRestored: return "purchase_restored" + case .subscriptionStatusChanged: return "subscription_status_changed" + case .themeChanged: return "theme_changed" + case .appearanceChanged: return "appearance_changed" + case .sportToggled: return "sport_toggled" + case .animationsToggled: return "animations_toggled" + case .drivingHoursChanged: return "driving_hours_changed" + case .analyticsToggled: return "analytics_toggled" + case .settingsReset: return "settings_reset" + case .pollCreated: return "poll_created" + case .pollVoted: return "poll_voted" + case .pollShared: return "poll_shared" + case .onboardingPaywallViewed: return "onboarding_paywall_viewed" + case .onboardingPaywallDismissed: return "onboarding_paywall_dismissed" + case .errorOccurred: return "error_occurred" + } + } + + var properties: [String: Any] { + switch self { + case .tabSwitched(let tab, let previousTab): + var props: [String: Any] = ["tab_name": tab] + if let prev = previousTab { props["previous_tab"] = prev } + return props + + case .screenViewed(let screen): + return ["screen_name": screen] + + case .tripWizardStarted(let mode): + return ["mode": mode] + + case .tripWizardStepCompleted(let step, let mode): + return ["step_name": step, "mode": mode] + + case .tripPlanned(let sportCount, let stopCount, let dayCount, let mode): + return ["sport_count": sportCount, "stop_count": stopCount, "day_count": dayCount, "mode": mode] + + case .tripPlanFailed(let mode, let error): + return ["mode": mode, "error": error] + + case .tripSaved(let tripId, let stopCount, let gameCount): + return ["trip_id": tripId, "stop_count": stopCount, "game_count": gameCount] + + case .tripDeleted(let tripId): + return ["trip_id": tripId] + + case .tripViewed(let tripId, let source): + return ["trip_id": tripId, "source": source] + + case .suggestedTripTapped(let region, let stopCount): + return ["region": region, "stop_count": stopCount] + + case .scheduleViewed(let sports): + return ["sports": sports] + + case .scheduleFiltered(let sport, let dateRange): + return ["sport": sport, "date_range": dateRange] + + case .gameTapped(let gameId, let sport, let homeTeam, let awayTeam): + return ["game_id": gameId, "sport": sport, "home_team": homeTeam, "away_team": awayTeam] + + case .stadiumVisitAdded(let stadiumId, let sport): + return ["stadium_id": stadiumId, "sport": sport] + + case .stadiumVisitDeleted(let stadiumId, let sport): + return ["stadium_id": stadiumId, "sport": sport] + + case .progressCardShared(let sport): + return ["sport": sport] + + case .sportSwitched(let sport): + return ["sport": sport] + + case .pdfExportStarted(let tripId, let stopCount): + return ["trip_id": tripId, "stop_count": stopCount] + + case .pdfExportCompleted(let tripId): + return ["trip_id": tripId] + + case .pdfExportFailed(let tripId, let error): + return ["trip_id": tripId, "error": error] + + case .tripShared(let tripId): + return ["trip_id": tripId] + + case .paywallViewed(let source): + return ["source": source] + + case .purchaseStarted(let productId): + return ["product_id": productId] + + case .purchaseCompleted(let productId): + return ["product_id": productId] + + case .purchaseFailed(let productId, let error): + return ["product_id": productId, "error": error] + + case .purchaseRestored: + return [:] + + case .subscriptionStatusChanged(let isPro, let plan): + var props: [String: Any] = ["is_pro": isPro] + if let plan { props["plan"] = plan } + return props + + case .themeChanged(let from, let to): + return ["from": from, "to": to] + + case .appearanceChanged(let mode): + return ["mode": mode] + + case .sportToggled(let sport, let enabled): + return ["sport": sport, "enabled": enabled] + + case .animationsToggled(let enabled): + return ["enabled": enabled] + + case .drivingHoursChanged(let hours): + return ["hours": hours] + + case .analyticsToggled(let enabled): + return ["enabled": enabled] + + case .settingsReset: + return [:] + + case .pollCreated(let optionCount): + return ["option_count": optionCount] + + case .pollVoted(let pollId): + return ["poll_id": pollId] + + case .pollShared(let pollId): + return ["poll_id": pollId] + + case .onboardingPaywallViewed: + return [:] + + case .onboardingPaywallDismissed: + return [:] + + case .errorOccurred(let domain, let message, let screen): + var props: [String: Any] = ["domain": domain, "message": message] + if let screen { props["screen"] = screen } + return props + } + } +} diff --git a/SportsTime/Core/Analytics/AnalyticsManager.swift b/SportsTime/Core/Analytics/AnalyticsManager.swift new file mode 100644 index 0000000..0d066e3 --- /dev/null +++ b/SportsTime/Core/Analytics/AnalyticsManager.swift @@ -0,0 +1,300 @@ +// +// AnalyticsManager.swift +// SportsTime +// +// Singleton analytics manager wrapping PostHog SDK. +// All analytics events flow through this single manager. +// + +import Foundation +import PostHog +import SwiftUI + +@MainActor +final class AnalyticsManager { + + // MARK: - Singleton + + static let shared = AnalyticsManager() + + // MARK: - Configuration + + private static let apiKey = "phc_SportsTime_production" + private static let host = "https://analytics.88oakapps.com" + private static let optOutKey = "analyticsOptedOut" + private static let sessionReplayKey = "analytics_session_replay_enabled" + private static let iso8601Formatter = ISO8601DateFormatter() + + // MARK: - State + + var isOptedOut: Bool { + UserDefaults.standard.bool(forKey: Self.optOutKey) + } + + var sessionReplayEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil { + return true + } + return UserDefaults.standard.bool(forKey: Self.sessionReplayKey) + } + set { + UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey) + if newValue { + PostHogSDK.shared.startSessionRecording() + } else { + PostHogSDK.shared.stopSessionRecording() + } + } + } + + private var isConfigured = false + + // MARK: - Initialization + + private init() {} + + // MARK: - Setup + + func configure() { + guard !isConfigured else { return } + + let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host) + + // Auto-capture + config.captureElementInteractions = true + config.captureApplicationLifecycleEvents = true + config.captureScreenViews = true + + // Session replay + config.sessionReplay = sessionReplayEnabled + config.sessionReplayConfig.maskAllTextInputs = true + config.sessionReplayConfig.maskAllImages = false + config.sessionReplayConfig.captureNetworkTelemetry = true + config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI + + // Respect user opt-out preference + if isOptedOut { + config.optOut = true + } + + #if DEBUG + config.debug = true + config.flushAt = 1 + #endif + + PostHogSDK.shared.setup(config) + isConfigured = true + + // Register super properties + registerSuperProperties() + } + + // MARK: - Super Properties + + func registerSuperProperties() { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + let device = UIDevice.current.model + let osVersion = UIDevice.current.systemVersion + let isPro = StoreManager.shared.isPro + let animationsEnabled = DesignStyleManager.shared.animationsEnabled + + // Load selected sports from UserDefaults + let selectedSports = UserDefaults.standard.stringArray(forKey: "selectedSports") ?? Sport.supported.map(\.rawValue) + + // Keep super-property keys aligned with Feels so dashboards can compare apps 1:1. + PostHogSDK.shared.register([ + "app_version": version, + "build_number": build, + "device_model": device, + "os_version": osVersion, + "is_pro": isPro, + "animations_enabled": animationsEnabled, + "selected_sports": selectedSports, + "theme": "n/a", + "icon_pack": "n/a", + "voting_layout": "n/a", + "day_view_style": "n/a", + "mood_shape": "n/a", + "personality_pack": "n/a", + "privacy_lock_enabled": false, + "healthkit_enabled": false, + "days_filter_count": 0, + "days_filter_all": false, + ]) + } + + func updateSuperProperties() { + registerSuperProperties() + } + + // MARK: - Event Tracking + + func track(_ event: AnalyticsEvent) { + guard isConfigured else { return } + PostHogSDK.shared.capture(event.name, properties: event.properties) + } + + // MARK: - Screen Tracking (manual supplement to auto-capture) + + func trackScreen(_ screenName: String, properties: [String: Any]? = nil) { + guard isConfigured else { return } + var props: [String: Any] = ["screen_name": screenName] + if let properties { props.merge(properties) { _, new in new } } + + #if DEBUG + print("[Analytics] screen_viewed: \(screenName)") + #endif + PostHogSDK.shared.capture("screen_viewed", properties: props) + } + + // MARK: - Subscription Funnel + + func trackPaywallViewed(source: String) { + guard isConfigured else { return } + PostHogSDK.shared.capture("paywall_viewed", properties: [ + "source": source + ]) + } + + func trackPurchaseStarted(productId: String, source: String) { + guard isConfigured else { return } + PostHogSDK.shared.capture("purchase_started", properties: [ + "product_id": productId, + "source": source + ]) + } + + func trackPurchaseCompleted(productId: String, source: String) { + guard isConfigured else { return } + PostHogSDK.shared.capture("purchase_completed", properties: [ + "product_id": productId, + "source": source + ]) + } + + func trackPurchaseFailed(productId: String?, source: String, error: String) { + guard isConfigured else { return } + var props: [String: Any] = [ + "source": source, + "error": error + ] + if let productId { + props["product_id"] = productId + } + PostHogSDK.shared.capture("purchase_failed", properties: props) + } + + func trackPurchaseRestored(source: String) { + guard isConfigured else { return } + PostHogSDK.shared.capture("purchase_restored", properties: [ + "source": source + ]) + } + + // MARK: - Opt In / Opt Out + + func optIn() { + UserDefaults.standard.set(false, forKey: Self.optOutKey) + if isConfigured { + PostHogSDK.shared.optIn() + PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true]) + } + } + + func optOut() { + if isConfigured { + PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false]) + } + UserDefaults.standard.set(true, forKey: Self.optOutKey) + if isConfigured { + PostHogSDK.shared.optOut() + } + } + + // MARK: - Person Properties (subscription segmentation) + + func updateSubscriptionStatus(_ status: String, type: String) { + guard isConfigured else { return } + PostHogSDK.shared.capture("$set", properties: [ + "$set": [ + "subscription_status": status, + "subscription_type": type + ] + ]) + } + + func trackSubscriptionStatusObserved( + status: String, + type: String, + source: String, + isSubscribed: Bool, + hasFullAccess: Bool, + productId: String?, + willAutoRenew: Bool?, + isInGracePeriod: Bool?, + trialDaysRemaining: Int?, + expirationDate: Date? + ) { + guard isConfigured else { return } + + var props: [String: Any] = [ + "status": status, + "type": type, + "source": source, + "is_subscribed": isSubscribed, + "has_full_access": hasFullAccess + ] + + if let productId { + props["product_id"] = productId + } + if let willAutoRenew { + props["will_auto_renew"] = willAutoRenew + } + if let isInGracePeriod { + props["is_in_grace_period"] = isInGracePeriod + } + if let trialDaysRemaining { + props["trial_days_remaining"] = trialDaysRemaining + } + if let expirationDate { + props["expiration_date"] = Self.iso8601Formatter.string(from: expirationDate) + } + + PostHogSDK.shared.capture("subscription_status_changed", properties: props) + updateSubscriptionStatus(status, type: type) + } + + // MARK: - Lifecycle + + func flush() { + guard isConfigured else { return } + PostHogSDK.shared.flush() + } + + func reset() { + guard isConfigured else { return } + PostHogSDK.shared.reset() + } +} + +// MARK: - SwiftUI Screen Tracking Modifier + +struct ScreenTrackingModifier: ViewModifier { + let screenName: String + let properties: [String: Any]? + + func body(content: Content) -> some View { + content.onAppear { + AnalyticsManager.shared.trackScreen(screenName, properties: properties) + } + } +} + +extension View { + func trackScreen(_ screenName: String, properties: [String: Any]? = nil) -> some View { + modifier(ScreenTrackingModifier(screenName: screenName, properties: properties)) + } +} diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift index 68fe1d2..258aaa8 100644 --- a/SportsTime/Core/Store/StoreManager.swift +++ b/SportsTime/Core/Store/StoreManager.swift @@ -31,6 +31,9 @@ final class StoreManager { private(set) var isLoading = false private(set) var error: StoreError? + /// Current subscription status details (nil if no subscription) + private(set) var subscriptionStatus: SubscriptionStatusInfo? + // MARK: - Debug Override (DEBUG builds only) #if DEBUG @@ -55,6 +58,10 @@ final class StoreManager { #if DEBUG if debugProOverride { return true } #endif + // Grant access if subscribed OR in grace period (billing retry) + if let status = subscriptionStatus, status.isInGracePeriod { + return true + } return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty } @@ -76,13 +83,37 @@ final class StoreManager { isLoading = true error = nil + print("[StoreManager] Loading products for IDs: \(Self.proProductIDs)") + do { - products = try await Product.products(for: Self.proProductIDs) - isLoading = false + let fetchedProducts = try await Product.products(for: Self.proProductIDs) + products = fetchedProducts + + print("[StoreManager] Loaded \(fetchedProducts.count) products:") + for product in fetchedProducts { + print("[StoreManager] - \(product.id): \(product.displayPrice) (\(product.displayName))") + if let sub = product.subscription { + print("[StoreManager] Subscription period: \(sub.subscriptionPeriod)") + if let intro = sub.introductoryOffer { + print("[StoreManager] Intro offer: \(intro.paymentMode) for \(intro.period), price: \(intro.displayPrice)") + } else { + print("[StoreManager] No intro offer") + } + } + } + + // Treat an empty fetch as a configuration issue so UI can show a useful fallback. + if fetchedProducts.isEmpty { + print("[StoreManager] WARNING: No products returned — check Configuration.storekit") + error = .productNotFound + } } catch { + products = [] self.error = .productNotFound - isLoading = false + print("[StoreManager] ERROR loading products: \(error)") } + + isLoading = false } // MARK: - Entitlement Management @@ -97,11 +128,73 @@ final class StoreManager { } purchasedProductIDs = purchased + + // Update subscription status details + await updateSubscriptionStatus() + trackSubscriptionAnalytics(source: "entitlements_refresh") + } + + // MARK: - Subscription Status + + private func updateSubscriptionStatus() async { + // Find a Pro product to check subscription status + guard let product = products.first(where: { Self.proProductIDs.contains($0.id) }), + let subscription = product.subscription else { + subscriptionStatus = nil + return + } + + do { + let statuses = try await subscription.status + guard let status = statuses.first(where: { $0.state != .revoked && $0.state != .expired }) ?? statuses.first else { + subscriptionStatus = nil + return + } + + let renewalInfo = try? status.renewalInfo.payloadValue + let transaction = try? status.transaction.payloadValue + + let planName: String + if let productID = transaction?.productID { + planName = productID.contains("annual") ? "Annual" : "Monthly" + } else { + planName = "Pro" + } + + let state: SubscriptionState + switch status.state { + case .subscribed: + state = .active + case .inBillingRetryPeriod: + state = .billingRetry + case .inGracePeriod: + state = .gracePeriod + case .expired: + state = .expired + case .revoked: + state = .revoked + default: + state = .active + } + + subscriptionStatus = SubscriptionStatusInfo( + state: state, + planName: planName, + productID: transaction?.productID, + expirationDate: transaction?.expirationDate, + gracePeriodExpirationDate: renewalInfo?.gracePeriodExpirationDate, + willAutoRenew: renewalInfo?.willAutoRenew ?? false, + isInGracePeriod: status.state == .inBillingRetryPeriod || status.state == .inGracePeriod + ) + } catch { + subscriptionStatus = nil + } } // MARK: - Purchase - func purchase(_ product: Product) async throws { + func purchase(_ product: Product, source: String = "store_manager") async throws { + AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source) let result = try await product.purchase() switch result { @@ -109,28 +202,91 @@ final class StoreManager { let transaction = try checkVerified(verification) await transaction.finish() await updateEntitlements() + AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source) + trackSubscriptionAnalytics(source: "purchase_success") case .userCancelled: + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled") throw StoreError.userCancelled case .pending: // Ask to Buy or SCA - transaction will appear in updates when approved - break + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending") @unknown default: + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown") throw StoreError.purchaseFailed } } // MARK: - Restore - func restorePurchases() async { + func restorePurchases(source: String = "settings") async { do { try await AppStore.sync() } catch { // Sync failed, but we can still check current entitlements + AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription) } await updateEntitlements() + AnalyticsManager.shared.trackPurchaseRestored(source: source) + trackSubscriptionAnalytics(source: "restore") + } + + // MARK: - Analytics + + func trackSubscriptionAnalytics(source: String) { + let status: String + let isSubscribed: Bool + + if let subscriptionStatus { + switch subscriptionStatus.state { + case .active: + status = "subscribed" + isSubscribed = true + case .billingRetry: + status = "billing_retry" + isSubscribed = true + case .gracePeriod: + status = "grace_period" + isSubscribed = true + case .expired: + status = "expired" + isSubscribed = false + case .revoked: + status = "revoked" + isSubscribed = false + } + } else { + status = isPro ? "subscribed" : "free" + isSubscribed = isPro + } + + let type = subscriptionType(for: subscriptionStatus?.productID) + AnalyticsManager.shared.trackSubscriptionStatusObserved( + status: status, + type: type, + source: source, + isSubscribed: isSubscribed, + hasFullAccess: isPro, + productId: subscriptionStatus?.productID, + willAutoRenew: subscriptionStatus?.willAutoRenew, + isInGracePeriod: subscriptionStatus?.isInGracePeriod, + trialDaysRemaining: nil, + expirationDate: subscriptionStatus?.expirationDate + ) + } + + private func subscriptionType(for productID: String?) -> String { + guard let productID else { return "none" } + let id = productID.lowercased() + if id.contains("annual") || id.contains("year") { + return "yearly" + } + if id.contains("month") { + return "monthly" + } + return "unknown" } // MARK: - Transaction Listener @@ -161,3 +317,57 @@ final class StoreManager { } } } + +// MARK: - Subscription Status Info + +enum SubscriptionState: String { + case active + case billingRetry + case gracePeriod + case expired + case revoked + + var displayName: String { + switch self { + case .active: return "Active" + case .billingRetry: return "Payment Issue" + case .gracePeriod: return "Grace Period" + case .expired: return "Expired" + case .revoked: return "Revoked" + } + } + + var isActive: Bool { + self == .active || self == .billingRetry || self == .gracePeriod + } +} + +struct SubscriptionStatusInfo { + let state: SubscriptionState + let planName: String + let productID: String? + let expirationDate: Date? + let gracePeriodExpirationDate: Date? + let willAutoRenew: Bool + let isInGracePeriod: Bool + + var renewalDescription: String { + guard let date = expirationDate else { return "" } + let formatter = DateFormatter() + formatter.dateStyle = .medium + + if state == .expired || state == .revoked { + return "Expired \(formatter.string(from: date))" + } + + if isInGracePeriod, let graceDate = gracePeriodExpirationDate { + return "Payment due by \(formatter.string(from: graceDate))" + } + + if willAutoRenew { + return "Renews \(formatter.string(from: date))" + } else { + return "Expires \(formatter.string(from: date))" + } + } +} diff --git a/SportsTime/Export/Views/ShareButton.swift b/SportsTime/Export/Views/ShareButton.swift index 2076040..6814552 100644 --- a/SportsTime/Export/Views/ShareButton.swift +++ b/SportsTime/Export/Views/ShareButton.swift @@ -16,6 +16,11 @@ struct ShareButton: View { var body: some View { Button { showPreview = true + if let tripContent = content as? TripShareContent { + AnalyticsManager.shared.track(.tripShared(tripId: tripContent.trip.id.uuidString)) + } else if content is ProgressShareContent { + AnalyticsManager.shared.track(.progressCardShared(sport: "unknown")) + } } label: { switch style { case .icon: diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 878f32b..dfe1bcd 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -91,6 +91,13 @@ struct HomeView: View { .tag(4) } .tint(Theme.warmOrange) + .onChange(of: selectedTab) { oldTab, newTab in + let tabNames = ["Home", "Schedule", "My Trips", "Progress", "Settings"] + let newName = newTab < tabNames.count ? tabNames[newTab] : "Unknown" + let oldName = oldTab < tabNames.count ? tabNames[oldTab] : nil + AnalyticsManager.shared.track(.tabSwitched(tab: newName, previousTab: oldName)) + AnalyticsManager.shared.trackScreen(newName) + } .sheet(isPresented: $showNewTrip) { TripWizardView() .environment(\.isDemoMode, ProcessInfo.isDemoMode) @@ -106,6 +113,9 @@ struct HomeView: View { } } .onAppear { + let tabNames = ["Home", "Schedule", "My Trips", "Progress", "Settings"] + let activeTabName = selectedTab < tabNames.count ? tabNames[selectedTab] : "Unknown" + AnalyticsManager.shared.trackScreen(activeTabName) if displayedTips.isEmpty { displayedTips = PlanningTips.random(3) } @@ -116,7 +126,7 @@ struct HomeView: View { } } .sheet(isPresented: $showProPaywall) { - PaywallView() + PaywallView(source: "home_progress_gate") } } @@ -209,6 +219,10 @@ struct HomeView: View { HStack(spacing: Theme.Spacing.md) { ForEach(regionGroup.trips) { suggestedTrip in Button { + AnalyticsManager.shared.track(.suggestedTripTapped( + region: regionGroup.region.shortName, + stopCount: suggestedTrip.trip.stops.count + )) selectedSuggestedTrip = suggestedTrip } label: { SuggestedTripCard(suggestedTrip: suggestedTrip) diff --git a/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift index 8570d58..9f71ee0 100644 --- a/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift +++ b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift @@ -30,7 +30,7 @@ struct ProGateModifier: ViewModifier { } } .sheet(isPresented: $showPaywall) { - PaywallView() + PaywallView(source: "pro_gate_\(feature.rawValue)") } } } @@ -53,7 +53,7 @@ struct ProGateButtonModifier: ViewModifier { content } .sheet(isPresented: $showPaywall) { - PaywallView() + PaywallView(source: "pro_gate_\(feature.rawValue)") } } } diff --git a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift index 17822cd..4d7ecd2 100644 --- a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -477,6 +477,9 @@ struct OnboardingPaywallView: View { .padding(.bottom, Theme.Spacing.xl) } .background(Theme.backgroundGradient(colorScheme)) + .onAppear { + AnalyticsManager.shared.track(.onboardingPaywallViewed) + } } // MARK: - Feature Page @@ -556,7 +559,7 @@ struct OnboardingPaywallView: View { // MARK: - Pricing Page private var pricingPage: some View { - PaywallView() + PaywallView(source: "onboarding") .storeButton(.hidden, for: .cancellation) } @@ -584,6 +587,7 @@ struct OnboardingPaywallView: View { // Continue free (always visible) Button { markOnboardingSeen() + AnalyticsManager.shared.track(.onboardingPaywallDismissed) isPresented = false } label: { Text("Continue with Free") diff --git a/SportsTime/Features/Paywall/Views/PaywallView.swift b/SportsTime/Features/Paywall/Views/PaywallView.swift index c3d097a..fafffb9 100644 --- a/SportsTime/Features/Paywall/Views/PaywallView.swift +++ b/SportsTime/Features/Paywall/Views/PaywallView.swift @@ -13,6 +13,11 @@ struct PaywallView: View { @Environment(\.colorScheme) private var colorScheme private let storeManager = StoreManager.shared + let source: String + + init(source: String = "unknown") { + self.source = source + } var body: some View { SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) { @@ -41,14 +46,34 @@ struct PaywallView: View { .storeButton(.visible, for: .restorePurchases) .subscriptionStoreControlStyle(.prominentPicker) .subscriptionStoreButtonLabel(.displayName.multiline) - .onInAppPurchaseCompletion { _, result in - if case .success(.success) = result { + .onInAppPurchaseStart { product in + AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source) + } + .onInAppPurchaseCompletion { product, result in + switch result { + case .success(.success(_)): + AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source) + Task { @MainActor in + await storeManager.updateEntitlements() + storeManager.trackSubscriptionAnalytics(source: "purchase_success") + } dismiss() + case .success(.userCancelled): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled") + case .success(.pending): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending") + case .failure(let error): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: error.localizedDescription) + @unknown default: + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown_result") } } .task { await storeManager.loadProducts() } + .onAppear { + AnalyticsManager.shared.trackPaywallViewed(source: source) + } } private func featurePill(icon: String, text: String) -> some View { diff --git a/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift index adeb753..3ba2b10 100644 --- a/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift +++ b/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift @@ -51,6 +51,7 @@ final class PollCreationViewModel { ) createdPoll = try await pollService.createPoll(poll) + AnalyticsManager.shared.track(.pollCreated(optionCount: selectedTrips.count)) } catch let pollError as PollError { error = pollError } catch { diff --git a/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift index 5bc972e..d7220d2 100644 --- a/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift +++ b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift @@ -52,6 +52,7 @@ final class PollVotingViewModel { _ = try await pollService.submitVote(vote) didSubmit = true + AnalyticsManager.shared.track(.pollVoted(pollId: pollId.uuidString)) } catch let pollError as PollError { error = pollError } catch { diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index 9223c8a..61db598 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -176,6 +176,7 @@ final class ProgressViewModel { func selectSport(_ sport: Sport) { selectedSport = sport + AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue)) } func clearError() { @@ -188,6 +189,10 @@ final class ProgressViewModel { func deleteVisit(_ visit: StadiumVisit) async throws { guard let container = modelContainer else { return } + if let sport = visit.sportEnum { + AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue)) + } + let context = ModelContext(container) context.delete(visit) try context.save() diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift index 9df8c17..86ca077 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -408,6 +408,7 @@ struct StadiumVisitSheet: View { do { try modelContext.save() + AnalyticsManager.shared.track(.stadiumVisitAdded(stadiumId: stadium.id, sport: selectedSport.rawValue)) onSave?(visit) dismiss() } catch { diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index fc4f128..e1f2649 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -111,6 +111,7 @@ final class ScheduleViewModel { self.diagnostics = newDiagnostics + AnalyticsManager.shared.track(.scheduleViewed(sports: Array(selectedSports).map(\.rawValue))) logger.info("📅 Returned \(self.games.count) games") for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { logger.info("📅 \(sport.rawValue): \(count) games") @@ -149,6 +150,7 @@ final class ScheduleViewModel { } else { selectedSports.insert(sport) } + AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))")) Task { await loadGames() } diff --git a/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift b/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift index 8e2b497..9256c23 100644 --- a/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift @@ -14,7 +14,9 @@ final class SettingsViewModel { var selectedTheme: AppTheme { didSet { + let oldName = oldValue.displayName ThemeManager.shared.currentTheme = selectedTheme + AnalyticsManager.shared.track(.themeChanged(from: oldName, to: selectedTheme.displayName)) } } @@ -23,7 +25,10 @@ final class SettingsViewModel { } var maxDrivingHoursPerDay: Int { - didSet { savePreferences() } + didSet { + savePreferences() + AnalyticsManager.shared.track(.drivingHoursChanged(hours: maxDrivingHoursPerDay)) + } } // MARK: - App Info @@ -63,8 +68,10 @@ final class SettingsViewModel { // Don't allow removing all sports guard selectedSports.count > 1 else { return } selectedSports.remove(sport) + AnalyticsManager.shared.track(.sportToggled(sport: sport.rawValue, enabled: false)) } else { selectedSports.insert(sport) + AnalyticsManager.shared.track(.sportToggled(sport: sport.rawValue, enabled: true)) } } @@ -73,6 +80,7 @@ final class SettingsViewModel { selectedSports = Set(Sport.supported) maxDrivingHoursPerDay = 8 AppearanceManager.shared.currentMode = .system + AnalyticsManager.shared.track(.settingsReset) } // MARK: - Persistence diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 3f38143..26cc192 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -39,6 +39,9 @@ struct SettingsView: View { // Travel Preferences travelSection + // Privacy + privacySection + // Icon Generator iconGeneratorSection @@ -79,6 +82,7 @@ struct SettingsView: View { Button { withAnimation(.easeInOut(duration: 0.2)) { AppearanceManager.shared.currentMode = mode + AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName)) } } label: { HStack(spacing: 12) { @@ -181,7 +185,10 @@ struct SettingsView: View { Section { Toggle(isOn: Binding( get: { DesignStyleManager.shared.animationsEnabled }, - set: { DesignStyleManager.shared.animationsEnabled = $0 } + set: { + DesignStyleManager.shared.animationsEnabled = $0 + AnalyticsManager.shared.track(.animationsToggled(enabled: $0)) + } )) { Label { VStack(alignment: .leading, spacing: 2) { @@ -257,6 +264,42 @@ struct SettingsView: View { .listRowBackground(Theme.cardBackground(colorScheme)) } + // MARK: - Privacy Section + + private var privacySection: some View { + Section { + Toggle(isOn: Binding( + get: { !AnalyticsManager.shared.isOptedOut }, + set: { enabled in + if enabled { + AnalyticsManager.shared.optIn() + } else { + AnalyticsManager.shared.optOut() + } + } + )) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Share Analytics") + .font(.body) + .foregroundStyle(.primary) + Text("Help improve SportsTime by sharing anonymous usage data") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "chart.bar.xaxis") + .foregroundStyle(Theme.warmOrange) + } + } + } header: { + Text("Privacy") + } footer: { + Text("No personal data is collected. Analytics are fully anonymous.") + } + .listRowBackground(Theme.cardBackground(colorScheme)) + } + // MARK: - About Section private var aboutSection: some View { @@ -573,7 +616,7 @@ struct SettingsView: View { Button { Task { - await StoreManager.shared.restorePurchases() + await StoreManager.shared.restorePurchases(source: "settings") } } label: { Label("Restore Purchases", systemImage: "arrow.clockwise") @@ -584,7 +627,7 @@ struct SettingsView: View { } .listRowBackground(Theme.cardBackground(colorScheme)) .sheet(isPresented: $showPaywall) { - PaywallView() + PaywallView(source: "settings") } } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 26ff6b4..499ff15 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -116,6 +116,7 @@ struct TripDetailView: View { Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?") } .onAppear { + AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new")) checkIfSaved() // Demo mode: auto-favorite the trip if isDemoMode && !hasAppliedDemoSelection && !isSaved { @@ -1203,6 +1204,7 @@ struct TripDetailView: View { private func exportPDF() async { isExporting = true exportProgress = nil + AnalyticsManager.shared.track(.pdfExportStarted(tripId: trip.id.uuidString, stopCount: trip.stops.count)) do { // Build complete itinerary items (games + travel + custom) @@ -1219,8 +1221,9 @@ struct TripDetailView: View { } exportURL = url showExportSheet = true + AnalyticsManager.shared.track(.pdfExportCompleted(tripId: trip.id.uuidString)) } catch { - // PDF export failed silently + AnalyticsManager.shared.track(.pdfExportFailed(tripId: trip.id.uuidString, error: error.localizedDescription)) } isExporting = false @@ -1323,6 +1326,11 @@ struct TripDetailView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = true } + AnalyticsManager.shared.track(.tripSaved( + tripId: trip.id.uuidString, + stopCount: trip.stops.count, + gameCount: trip.totalGames + )) } catch { // Save failed silently } @@ -1343,6 +1351,7 @@ struct TripDetailView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = false } + AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString)) } catch { // Unsave failed silently } @@ -2047,7 +2056,7 @@ private struct SheetModifiers: ViewModifier { } } .sheet(isPresented: $showProPaywall) { - PaywallView() + PaywallView(source: "trip_detail") } .sheet(item: $addItemAnchor) { anchor in QuickAddItemSheet( diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index bdc11cc..0087bd9 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -165,6 +165,8 @@ struct TripWizardView: View { // MARK: - Planning private func planTrip() async { + let mode = viewModel.planningMode?.rawValue ?? "unknown" + AnalyticsManager.shared.track(.tripWizardStarted(mode: mode)) viewModel.isPlanning = true defer { viewModel.isPlanning = false } @@ -242,18 +244,29 @@ struct TripWizardView: View { if options.isEmpty { planningError = "No valid trip options found for your criteria. Try expanding your date range or regions." showError = true + AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: "no_options_found")) } else { tripOptions = options gamesForDisplay = richGamesDict showTripOptions = true + if let first = options.first { + AnalyticsManager.shared.track(.tripPlanned( + sportCount: viewModel.selectedSports.count, + stopCount: first.stops.count, + dayCount: first.stops.count, + mode: mode + )) + } } case .failure(let failure): planningError = failure.message showError = true + AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: failure.message)) } } catch { planningError = error.localizedDescription showError = true + AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: error.localizedDescription)) } } diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 2e6923c..4006193 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -134,6 +134,10 @@ struct BootstrappedContentView: View { .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: + // Refresh super properties (subscription status may have changed) + AnalyticsManager.shared.updateSuperProperties() + // Track subscription state with rich properties for funnel analysis + StoreManager.shared.trackSubscriptionAnalytics(source: "app_foreground") // Sync when app comes to foreground (but not on initial launch) if hasCompletedInitialSync { Task { @@ -141,6 +145,8 @@ struct BootstrappedContentView: View { } } case .background: + // Flush pending analytics events + AnalyticsManager.shared.flush() // Schedule background tasks when app goes to background BackgroundSyncManager.shared.scheduleAllTasks() default: @@ -190,15 +196,19 @@ struct BootstrappedContentView: View { } NetworkMonitor.shared.startMonitoring() - // 7. App is now usable - print("🚀 [BOOT] Step 7: Bootstrap complete - app ready") + // 7. Configure analytics + print("🚀 [BOOT] Step 7: Configuring analytics...") + AnalyticsManager.shared.configure() + + // 8. App is now usable + print("🚀 [BOOT] Step 8: Bootstrap complete - app ready") isBootstrapping = false - // 8. Schedule background tasks for future syncs + // 9. Schedule background tasks for future syncs BackgroundSyncManager.shared.scheduleAllTasks() - // 9. Background: Try to refresh from CloudKit (non-blocking) - print("🚀 [BOOT] Step 9: Starting background CloudKit sync...") + // 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) await MainActor.run {