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:
Trey t
2026-02-10 15:12:16 -06:00
parent 5389fe3759
commit 2917ae22b1
20 changed files with 989 additions and 23 deletions

View File

@@ -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))"
}
}
}