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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user