From c013edc5686731e95d6e78dc2d3a9bfd7c355f2b Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sat, 14 Mar 2026 23:04:41 -0700 Subject: [PATCH 1/2] feat: add Firebase Firestore engine telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-engine timing and trace upload to Firestore for remote quality baselining. Each dashboard refresh records computed scores, confidence levels, and durations — never raw HealthKit values — tied to a SHA256- hashed Apple Sign-In user ID. - Add Firebase SDK (FirebaseFirestore) to iOS target via SPM - Create PipelineTrace model with per-engine sub-structs - Create EngineTelemetryService singleton for Firestore uploads - Create FirestoreAnalyticsProvider for general analytics events - Instrument DashboardViewModel.refresh() with per-engine timing - Add telemetry consent toggle in Settings (always on in DEBUG) - Initialize Firebase and telemetry service at app startup --- .../HeartCoach/iOS/Models/PipelineTrace.swift | 313 ++++++++++++++++++ .../iOS/Services/EngineTelemetryService.swift | 111 +++++++ .../Services/FirestoreAnalyticsProvider.swift | 55 +++ apps/HeartCoach/iOS/ThumpiOSApp.swift | 7 + .../iOS/ViewModels/DashboardViewModel.swift | 38 +++ apps/HeartCoach/iOS/Views/SettingsView.swift | 23 ++ apps/HeartCoach/project.yml | 13 + 7 files changed, 560 insertions(+) create mode 100644 apps/HeartCoach/iOS/Models/PipelineTrace.swift create mode 100644 apps/HeartCoach/iOS/Services/EngineTelemetryService.swift create mode 100644 apps/HeartCoach/iOS/Services/FirestoreAnalyticsProvider.swift diff --git a/apps/HeartCoach/iOS/Models/PipelineTrace.swift b/apps/HeartCoach/iOS/Models/PipelineTrace.swift new file mode 100644 index 00000000..fde214d9 --- /dev/null +++ b/apps/HeartCoach/iOS/Models/PipelineTrace.swift @@ -0,0 +1,313 @@ +// PipelineTrace.swift +// Thump iOS +// +// Data model capturing a complete engine pipeline run for telemetry. +// Each trace records computed scores, confidence levels, timing, and +// metadata — never raw HealthKit values. Converted to Firestore- +// friendly dictionaries for upload. +// Platforms: iOS 17+ + +import Foundation +import FirebaseFirestore + +// MARK: - Pipeline Trace + +/// Captures one full dashboard refresh pipeline run for telemetry. +/// +/// Contains per-engine scores, confidence levels, durations, and +/// counts — but never raw HealthKit values (RHR, HRV, steps, etc.). +/// Converted to a `[String: Any]` dictionary for Firestore upload. +struct PipelineTrace { + + // MARK: - Metadata + + /// When the pipeline ran. + let timestamp: Date + + /// Total wall time for the refresh in milliseconds. + let pipelineDurationMs: Double + + /// Number of history days used as engine input. + let historyDays: Int + + // MARK: - Engine Results (all optional) + + var heartTrend: HeartTrendTrace? + var stress: StressTrace? + var readiness: ReadinessTrace? + var bioAge: BioAgeTrace? + var coaching: CoachingTrace? + var zoneAnalysis: ZoneAnalysisTrace? + var buddy: BuddyTrace? + + // MARK: - Firestore Conversion + + /// Converts the trace to a Firestore-compatible dictionary. + /// + /// Includes app version, build number, device model, and a + /// server timestamp for consistent ordering. + func toFirestoreData() -> [String: Any] { + var data: [String: Any] = [ + "timestamp": FieldValue.serverTimestamp(), + "clientTimestamp": Timestamp(date: timestamp), + "pipelineDurationMs": pipelineDurationMs, + "historyDays": historyDays, + "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", + "buildNumber": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown", + "deviceModel": deviceModel() + ] + + if let heartTrend { data["heartTrend"] = heartTrend.toDict() } + if let stress { data["stress"] = stress.toDict() } + if let readiness { data["readiness"] = readiness.toDict() } + if let bioAge { data["bioAge"] = bioAge.toDict() } + if let coaching { data["coaching"] = coaching.toDict() } + if let zoneAnalysis { data["zoneAnalysis"] = zoneAnalysis.toDict() } + if let buddy { data["buddy"] = buddy.toDict() } + + return data + } + + /// Returns the hardware model identifier (e.g., "iPhone16,1"). + private func deviceModel() -> String { + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) ?? "unknown" + } + } + } +} + +// MARK: - Heart Trend Trace + +/// Telemetry data from the HeartTrendEngine. +struct HeartTrendTrace { + let status: String + let confidence: String + let anomalyScore: Double + let regressionFlag: Bool + let stressFlag: Bool + let cardioScore: Double? + let scenario: String? + let nudgeCategory: String + let nudgeCount: Int + let hasWeekOverWeek: Bool + let hasConsecutiveAlert: Bool + let hasRecoveryTrend: Bool + let durationMs: Double + + init(from assessment: HeartAssessment, durationMs: Double) { + self.status = assessment.status.rawValue + self.confidence = assessment.confidence.rawValue + self.anomalyScore = assessment.anomalyScore + self.regressionFlag = assessment.regressionFlag + self.stressFlag = assessment.stressFlag + self.cardioScore = assessment.cardioScore + self.scenario = assessment.scenario?.rawValue + self.nudgeCategory = assessment.dailyNudge.category.rawValue + self.nudgeCount = assessment.dailyNudges.count + self.hasWeekOverWeek = assessment.weekOverWeekTrend != nil + self.hasConsecutiveAlert = assessment.consecutiveAlert != nil + self.hasRecoveryTrend = assessment.recoveryTrend != nil + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + var d: [String: Any] = [ + "status": status, + "confidence": confidence, + "anomalyScore": anomalyScore, + "regressionFlag": regressionFlag, + "stressFlag": stressFlag, + "nudgeCategory": nudgeCategory, + "nudgeCount": nudgeCount, + "hasWeekOverWeek": hasWeekOverWeek, + "hasConsecutiveAlert": hasConsecutiveAlert, + "hasRecoveryTrend": hasRecoveryTrend, + "durationMs": durationMs + ] + if let cardioScore { d["cardioScore"] = cardioScore } + if let scenario { d["scenario"] = scenario } + return d + } +} + +// MARK: - Stress Trace + +/// Telemetry data from the StressEngine. +struct StressTrace { + let score: Double + let level: String + let mode: String + let confidence: String + let durationMs: Double + + init(from result: StressResult, durationMs: Double) { + self.score = result.score + self.level = result.level.rawValue + self.mode = result.mode.rawValue + self.confidence = result.confidence.rawValue + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + [ + "score": score, + "level": level, + "mode": mode, + "confidence": confidence, + "durationMs": durationMs + ] + } +} + +// MARK: - Readiness Trace + +/// Telemetry data from the ReadinessEngine. +struct ReadinessTrace { + let score: Int + let level: String + let pillarScores: [String: Double] + let durationMs: Double + + init(from result: ReadinessResult, durationMs: Double) { + self.score = result.score + self.level = result.level.rawValue + var pillars: [String: Double] = [:] + for pillar in result.pillars { + pillars[pillar.type.rawValue] = Double(pillar.score) + } + self.pillarScores = pillars + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + [ + "score": score, + "level": level, + "pillarScores": pillarScores, + "durationMs": durationMs + ] + } +} + +// MARK: - Bio Age Trace + +/// Telemetry data from the BioAgeEngine. +struct BioAgeTrace { + let bioAge: Int + let chronologicalAge: Int + let difference: Int + let category: String + let metricsUsed: Int + let durationMs: Double + + init(from result: BioAgeResult, durationMs: Double) { + self.bioAge = result.bioAge + self.chronologicalAge = result.chronologicalAge + self.difference = result.difference + self.category = result.category.rawValue + self.metricsUsed = result.metricsUsed + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + [ + "bioAge": bioAge, + "chronologicalAge": chronologicalAge, + "difference": difference, + "category": category, + "metricsUsed": metricsUsed, + "durationMs": durationMs + ] + } +} + +// MARK: - Coaching Trace + +/// Telemetry data from the CoachingEngine. +struct CoachingTrace { + let weeklyProgressScore: Int + let insightCount: Int + let projectionCount: Int + let streakDays: Int + let durationMs: Double + + init(from report: CoachingReport, durationMs: Double) { + self.weeklyProgressScore = report.weeklyProgressScore + self.insightCount = report.insights.count + self.projectionCount = report.projections.count + self.streakDays = report.streakDays + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + [ + "weeklyProgressScore": weeklyProgressScore, + "insightCount": insightCount, + "projectionCount": projectionCount, + "streakDays": streakDays, + "durationMs": durationMs + ] + } +} + +// MARK: - Zone Analysis Trace + +/// Telemetry data from the HeartRateZoneEngine. +struct ZoneAnalysisTrace { + let overallScore: Int + let pillarCount: Int + let hasRecommendation: Bool + let durationMs: Double + + init(from analysis: ZoneAnalysis, durationMs: Double) { + self.overallScore = analysis.overallScore + self.pillarCount = analysis.pillars.count + self.hasRecommendation = analysis.recommendation != nil + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + [ + "overallScore": overallScore, + "pillarCount": pillarCount, + "hasRecommendation": hasRecommendation, + "durationMs": durationMs + ] + } +} + +// MARK: - Buddy Trace + +/// Telemetry data from the BuddyRecommendationEngine. +struct BuddyTrace { + let count: Int + let topPriority: String? + let topCategory: String? + let durationMs: Double + + init(from recommendations: [BuddyRecommendation], durationMs: Double) { + self.count = recommendations.count + if let first = recommendations.first { + self.topPriority = String(describing: first.priority) + self.topCategory = first.category.rawValue + } else { + self.topPriority = nil + self.topCategory = nil + } + self.durationMs = durationMs + } + + func toDict() -> [String: Any] { + var d: [String: Any] = [ + "count": count, + "durationMs": durationMs + ] + if let topPriority { d["topPriority"] = topPriority } + if let topCategory { d["topCategory"] = topCategory } + return d + } +} diff --git a/apps/HeartCoach/iOS/Services/EngineTelemetryService.swift b/apps/HeartCoach/iOS/Services/EngineTelemetryService.swift new file mode 100644 index 00000000..7557bf92 --- /dev/null +++ b/apps/HeartCoach/iOS/Services/EngineTelemetryService.swift @@ -0,0 +1,111 @@ +// EngineTelemetryService.swift +// Thump iOS +// +// Uploads engine pipeline traces to Firebase Firestore for quality +// baselining. Uses a SHA256-hashed Apple Sign-In user ID for +// pseudonymous tracking. Gated behind a user consent toggle +// (always enabled in DEBUG builds). +// Platforms: iOS 17+ + +import Foundation +import CryptoKit +import FirebaseFirestore + +// MARK: - Engine Telemetry Service + +/// Uploads ``PipelineTrace`` documents to Firestore for engine quality +/// baselining and debugging. +/// +/// Each trace captures computed scores, confidence levels, and timing +/// from all 9 engines — never raw HealthKit values. Documents are stored +/// under `users/{hashedUserId}/traces/{autoId}`. +/// +/// Usage: +/// ```swift +/// // At startup: +/// EngineTelemetryService.shared.configureUserId() +/// +/// // After each dashboard refresh: +/// EngineTelemetryService.shared.uploadTrace(trace) +/// ``` +final class EngineTelemetryService { + + // MARK: - Singleton + + static let shared = EngineTelemetryService() + + // MARK: - Properties + + /// The SHA256-hashed Apple user identifier for pseudonymous tracking. + private(set) var hashedUserId: String? + + /// Firestore database reference. + private let db = Firestore.firestore() + + // MARK: - Initialization + + private init() {} + + // MARK: - User ID Configuration + + /// Loads the Apple Sign-In user identifier from the Keychain and + /// creates a SHA256 hash for pseudonymous Firestore document paths. + /// + /// Call this after verifying the Apple Sign-In credential in + /// `performStartupTasks()`. + func configureUserId() { + guard let appleId = AppleSignInService.loadUserIdentifier() else { + AppLogger.engine.warning("[EngineTelemetry] No Apple user ID found — telemetry disabled.") + return + } + + let hash = SHA256.hash(data: Data(appleId.utf8)) + hashedUserId = hash.compactMap { String(format: "%02x", $0) }.joined() + AppLogger.engine.info("[EngineTelemetry] User ID configured (hashed).") + } + + // MARK: - Consent Check + + /// Whether telemetry uploads are enabled. + /// + /// Always `true` in DEBUG builds. In production, reads the user's + /// opt-in preference from `@AppStorage("thump_telemetry_consent")`. + var isUploadEnabled: Bool { + #if DEBUG + return true + #else + return UserDefaults.standard.bool(forKey: "thump_telemetry_consent") + #endif + } + + // MARK: - Upload + + /// Uploads a complete pipeline trace document to Firestore. + /// + /// Fire-and-forget: the write is queued by the Firestore SDK + /// (including offline persistence) and errors are logged but + /// never surfaced to the user. + /// + /// - Parameter trace: The pipeline trace to upload. + func uploadTrace(_ trace: PipelineTrace) { + guard isUploadEnabled else { return } + + guard let userId = hashedUserId else { + AppLogger.engine.debug("[EngineTelemetry] No user ID — skipping trace upload.") + return + } + + let docData = trace.toFirestoreData() + + db.collection("users") + .document(userId) + .collection("traces") + .addDocument(data: docData) { error in + if let error { + AppLogger.engine.warning("[EngineTelemetry] Upload failed: \(error.localizedDescription)") + } else { + AppLogger.engine.debug("[EngineTelemetry] Trace uploaded successfully.") + } + } + } +} diff --git a/apps/HeartCoach/iOS/Services/FirestoreAnalyticsProvider.swift b/apps/HeartCoach/iOS/Services/FirestoreAnalyticsProvider.swift new file mode 100644 index 00000000..b056545e --- /dev/null +++ b/apps/HeartCoach/iOS/Services/FirestoreAnalyticsProvider.swift @@ -0,0 +1,55 @@ +// FirestoreAnalyticsProvider.swift +// Thump iOS +// +// Implements the AnalyticsProvider protocol to route general +// analytics events (screen views, sign-in, nudge completions) +// to a Firestore sub-collection under the user's hashed ID. +// Platforms: iOS 17+ + +import Foundation +import FirebaseFirestore + +// MARK: - Firestore Analytics Provider + +/// Routes general analytics events to Firestore. +/// +/// Events are stored under `users/{hashedUserId}/events/{autoId}` +/// with a server timestamp for ordering. This provider is registered +/// with the shared ``Analytics`` instance at app startup. +/// +/// Respects the same consent and user-ID gating as +/// ``EngineTelemetryService`` to avoid uploading without permission. +struct FirestoreAnalyticsProvider: AnalyticsProvider { + + /// Tracks an analytics event by writing it to Firestore. + /// + /// - Parameter event: The event to track. + func track(event: AnalyticsEvent) { + let telemetry = EngineTelemetryService.shared + + guard telemetry.isUploadEnabled, + let userId = telemetry.hashedUserId else { + return + } + + var data: [String: Any] = [ + "event": event.name, + "timestamp": FieldValue.serverTimestamp() + ] + + // Merge event properties into the document + for (key, value) in event.properties { + data[key] = value + } + + Firestore.firestore() + .collection("users") + .document(userId) + .collection("events") + .addDocument(data: data) { error in + if let error { + AppLogger.engine.debug("[FirestoreAnalytics] Event upload failed: \(error.localizedDescription)") + } + } + } +} diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift index 8d2f281f..9272cbed 100644 --- a/apps/HeartCoach/iOS/ThumpiOSApp.swift +++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift @@ -8,6 +8,7 @@ // Platforms: iOS 17+ import SwiftUI +import FirebaseCore // MARK: - App Entry Point @@ -45,6 +46,8 @@ struct ThumpiOSApp: App { // MARK: - Initialization init() { + FirebaseApp.configure() + let store = LocalStore() _localStore = StateObject(wrappedValue: store) _notificationService = StateObject(wrappedValue: NotificationService(localStore: store)) @@ -137,6 +140,10 @@ struct ThumpiOSApp: App { } } + // Configure engine telemetry for quality baselining + EngineTelemetryService.shared.configureUserId() + Analytics.shared.register(provider: FirestoreAnalyticsProvider()) + // Start MetricKit crash reporting and performance monitoring MetricKitService.shared.start() diff --git a/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift b/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift index a24bee5d..3bcdfc78 100644 --- a/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift +++ b/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift @@ -219,23 +219,33 @@ final class DashboardViewModel: ObservableObject { loadTodayCheckIn() // Compute bio age if user has set date of birth + let bioAgeStart = CFAbsoluteTimeGetCurrent() computeBioAge(snapshot: snapshot) + let bioAgeMs = (CFAbsoluteTimeGetCurrent() - bioAgeStart) * 1000 // Compute readiness score + let readinessStart = CFAbsoluteTimeGetCurrent() computeReadiness(snapshot: snapshot, history: history) + let readinessMs = (CFAbsoluteTimeGetCurrent() - readinessStart) * 1000 // Compute coaching report + let coachingStart = CFAbsoluteTimeGetCurrent() computeCoachingReport(snapshot: snapshot, history: history) + let coachingMs = (CFAbsoluteTimeGetCurrent() - coachingStart) * 1000 // Compute zone analysis + let zoneStart = CFAbsoluteTimeGetCurrent() computeZoneAnalysis(snapshot: snapshot) + let zoneMs = (CFAbsoluteTimeGetCurrent() - zoneStart) * 1000 // Compute buddy recommendations (after readiness and stress are available) + let buddyStart = CFAbsoluteTimeGetCurrent() computeBuddyRecommendations( assessment: result, snapshot: snapshot, history: history ) + let buddyMs = (CFAbsoluteTimeGetCurrent() - buddyStart) * 1000 // Schedule notifications from live assessment output (CR-001) scheduleNotificationsIfNeeded(assessment: result, history: history) @@ -244,6 +254,34 @@ final class DashboardViewModel: ObservableObject { AppLogger.engine.info("Dashboard refresh complete in \(String(format: "%.0f", totalMs))ms — history=\(history.count) days") isLoading = false + + // Upload engine pipeline trace for quality baselining + var trace = PipelineTrace( + timestamp: Date(), + pipelineDurationMs: totalMs, + historyDays: history.count + ) + trace.heartTrend = HeartTrendTrace(from: result, durationMs: engineMs) + if let s = stressResult { + // Stress duration is included in buddyMs (computed inside computeBuddyRecommendations) + trace.stress = StressTrace(from: s, durationMs: buddyMs) + } + if let r = readinessResult { + trace.readiness = ReadinessTrace(from: r, durationMs: readinessMs) + } + if let b = bioAgeResult { + trace.bioAge = BioAgeTrace(from: b, durationMs: bioAgeMs) + } + if let c = coachingReport { + trace.coaching = CoachingTrace(from: c, durationMs: coachingMs) + } + if let z = zoneAnalysis { + trace.zoneAnalysis = ZoneAnalysisTrace(from: z, durationMs: zoneMs) + } + if let recs = buddyRecommendations { + trace.buddy = BuddyTrace(from: recs, durationMs: buddyMs) + } + EngineTelemetryService.shared.uploadTrace(trace) } catch { AppLogger.engine.error("Dashboard refresh failed: \(error.localizedDescription)") errorMessage = error.localizedDescription diff --git a/apps/HeartCoach/iOS/Views/SettingsView.swift b/apps/HeartCoach/iOS/Views/SettingsView.swift index be78bf77..88b4887b 100644 --- a/apps/HeartCoach/iOS/Views/SettingsView.swift +++ b/apps/HeartCoach/iOS/Views/SettingsView.swift @@ -29,6 +29,10 @@ struct SettingsView: View { @AppStorage("thump_nudge_reminders_enabled") private var nudgeRemindersEnabled: Bool = true + /// Whether anonymous engine telemetry is enabled. + @AppStorage("thump_telemetry_consent") + private var telemetryConsent: Bool = false + /// Controls presentation of the paywall sheet. @State private var showPaywall: Bool = false @@ -62,6 +66,7 @@ struct SettingsView: View { subscriptionSection feedbackPreferencesSection notificationsSection + analyticsSection dataSection bugReportSection aboutSection @@ -238,6 +243,24 @@ struct SettingsView: View { } } + // MARK: - Analytics Section + + private var analyticsSection: some View { + Section { + Toggle(isOn: $telemetryConsent) { + Label("Share Engine Insights", systemImage: "chart.bar.xaxis.ascending") + } + .tint(.pink) + .onChange(of: telemetryConsent) { _, newValue in + InteractionLog.log(.toggleChange, element: "telemetry_consent_toggle", page: "Settings", details: "enabled=\(newValue)") + } + } header: { + Text("Analytics") + } footer: { + Text("Help improve Thump by sharing anonymized engine scores and timing data. No raw health data (heart rate, HRV, steps, etc.) is ever shared.") + } + } + // MARK: - Feedback Preferences Section private var feedbackPreferencesSection: some View { diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml index 68e45287..9e0dda68 100644 --- a/apps/HeartCoach/project.yml +++ b/apps/HeartCoach/project.yml @@ -24,6 +24,15 @@ settings: DEAD_CODE_STRIPPING: true CODE_SIGN_STYLE: Automatic +############################################################ +# Swift Package Manager Dependencies +############################################################ + +packages: + Firebase: + url: https://github.com/firebase/firebase-ios-sdk + from: "11.0.0" + ############################################################ # Targets ############################################################ @@ -43,6 +52,8 @@ targets: resources: - path: iOS/PrivacyInfo.xcprivacy - path: iOS/Assets.xcassets + - path: iOS/GoogleService-Info.plist + optional: true settings: base: INFOPLIST_FILE: iOS/Info.plist @@ -56,6 +67,8 @@ targets: - sdk: StoreKit.framework - sdk: AppIntents.framework - sdk: AuthenticationServices.framework + - package: Firebase + product: FirebaseFirestore scheme: testTargets: - ThumpCoreTests From fa37c29bb7ede7f579d70f1d3a5ad5687ee0921e Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sat, 14 Mar 2026 23:50:50 -0700 Subject: [PATCH 2/2] feat: 1-year free launch offer, Firestore integration tests, and legal docs - Add launch free year: all users get full Coach access for 1 year from first sign-in with no subscription required - Add LaunchCongratsView shown once after first sign-in - Update Settings subscription section to show free year status with days remaining instead of upgrade button - Add Firestore telemetry integration tests that upload mock health data through all 9 engines and read back to validate - Add privacy policy and terms of service covering HealthKit, Firebase telemetry, push notifications, and solo dev protections - Add GoogleService-Info.plist to .gitignore --- .gitignore | 3 + apps/HeartCoach/Legal/privacy-policy.md | 188 +++++++++ apps/HeartCoach/Legal/terms-of-service.md | 193 ++++++++++ .../Shared/Models/HeartModels.swift | 23 +- .../FirestoreTelemetryIntegrationTests.swift | 358 ++++++++++++++++++ apps/HeartCoach/iOS/ThumpiOSApp.swift | 38 +- .../iOS/Views/LaunchCongratsView.swift | 144 +++++++ apps/HeartCoach/iOS/Views/SettingsView.swift | 66 +++- 8 files changed, 984 insertions(+), 29 deletions(-) create mode 100644 apps/HeartCoach/Legal/privacy-policy.md create mode 100644 apps/HeartCoach/Legal/terms-of-service.md create mode 100644 apps/HeartCoach/Tests/FirestoreTelemetryIntegrationTests.swift create mode 100644 apps/HeartCoach/iOS/Views/LaunchCongratsView.swift diff --git a/.gitignore b/.gitignore index b71744fe..b8e15fbd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ DerivedData/ .build/ build/ +# Firebase config (contains API keys) +GoogleService-Info.plist + # Swift Package Manager .swiftpm/ Packages/ diff --git a/apps/HeartCoach/Legal/privacy-policy.md b/apps/HeartCoach/Legal/privacy-policy.md new file mode 100644 index 00000000..83f9452c --- /dev/null +++ b/apps/HeartCoach/Legal/privacy-policy.md @@ -0,0 +1,188 @@ +# Privacy Policy + +**Last Updated: March 14, 2026** + +Thump ("we," "our," or "the app") is a heart health and wellness application for iPhone and Apple Watch. This Privacy Policy explains how we collect, use, store, and protect your information when you use Thump. + +By using Thump, you consent to the data practices described in this policy. + +--- + +## 1. Information We Collect + +### 1.1 Health and Fitness Data (Apple HealthKit) + +With your explicit permission, Thump reads the following data from Apple Health: + +- Resting heart rate +- Heart rate variability (HRV) +- Heart rate recovery +- VO2 max +- Step count +- Walking and running distance +- Active energy burned +- Exercise minutes +- Sleep analysis +- Body weight +- Height +- Biological sex +- Date of birth + +**Important:** We only read this data to generate wellness insights. We never sell, share, or use your raw health data for advertising, marketing, or data mining purposes. + +### 1.2 Account Information + +When you sign in with Apple, we receive an anonymous, app-specific identifier issued by Apple. We do not receive or store your name, email address, or other personal information from your Apple ID. + +### 1.3 Subscription Information + +Thump is free for the first year with full access to all features. No payment information is collected during this period. If you choose to subscribe after the free period, Apple processes your payment. We only receive confirmation of your subscription tier and its status. We do not have access to your payment method, credit card number, or billing address. + +### 1.4 Usage Analytics (Opt-In) + +If you enable "Share Engine Insights" in Settings, we collect anonymized performance data about how our wellness engines compute your scores. This includes: + +- Computed wellness scores (e.g., readiness score, stress level, bio age) +- Engine confidence levels and timing data +- App version, build number, and device model + +**This data never includes your raw health values** (heart rate, HRV, steps, sleep hours, etc.). Only the computed scores and engine performance metrics are collected. + +You can disable this at any time in Settings > Analytics. + +In debug/development builds, this data collection is enabled by default for quality assurance purposes. + +### 1.5 Device Information + +We may collect basic device information such as device model (e.g., "iPhone 16") for engine performance analysis. We do not collect device identifiers (UDID, IDFA) or location data. + +--- + +## 2. How We Use Your Information + +We use the information we collect to: + +- **Provide wellness insights:** Analyze your health data to generate heart trend assessments, readiness scores, stress levels, bio age estimates, coaching recommendations, and daily nudges. +- **Sync between devices:** Transfer wellness insights (not raw health data) between your iPhone and Apple Watch via WatchConnectivity. +- **Send local notifications:** Deliver anomaly alerts and wellness nudges directly on your device. Notification content never includes specific health metric values. +- **Improve our engines:** If you opt in, anonymized engine performance data helps us improve the accuracy of our wellness algorithms. +- **Manage subscriptions:** Determine which features are available based on your subscription tier. + +--- + +## 3. How We Store Your Information + +### 3.1 On-Device Storage + +Your health data is stored locally on your device using AES-256-GCM encryption. Data is stored in the app's sandboxed container and protected by your device's passcode and biometric authentication. + +- Health snapshot history: up to 365 days stored locally +- User profile and preferences: stored in encrypted local storage +- Apple Sign-In identifier: stored in the iOS Keychain + +### 3.2 Cloud Storage + +If you opt in to "Share Engine Insights," anonymized engine performance data is stored in Google Firebase Firestore. This data is: + +- Linked to a pseudonymous identifier (a one-way SHA-256 hash of your Apple Sign-In ID) +- Stored on Google Cloud infrastructure with encryption at rest and in transit +- Not linked to your real identity, email, or personal information +- Retained for engine quality analysis purposes + +**We do not store raw health data in the cloud.** Your heart rate, HRV, sleep, steps, and other HealthKit values never leave your device. + +### 3.3 iCloud + +We do not store any health or personal data in iCloud. + +--- + +## 4. How We Share Your Information + +**We do not sell your data.** We do not share your information with third parties for advertising, marketing, or data mining purposes. + +We may share limited information with the following service providers: + +| Service | Data Shared | Purpose | +|---------|------------|---------| +| Apple (HealthKit) | Health data remains on device | Reading health metrics | +| Apple (Sign in with Apple) | Anonymous user identifier | Authentication | +| Apple (StoreKit) | Subscription status | Payment processing | +| Google Firebase Firestore | Anonymized engine scores, device model, app version | Engine quality analysis (opt-in only) | + +No other third parties receive any data from Thump. + +--- + +## 5. Push Notifications + +Thump uses **local notifications only** (not remote/cloud push notifications). Notifications are generated entirely on your device based on your health assessments. + +- **Anomaly alerts:** Notify you when your health metrics deviate from your personal baseline. +- **Wellness nudges:** Remind you about daily wellness activities (walking, hydration, breathing exercises, etc.). + +Notification content never includes specific health metric values (e.g., your actual heart rate number). You can disable notifications at any time in your device's Settings. + +--- + +## 6. Data Retention + +- **On-device data:** Retained as long as you use the app. Deleted when you uninstall Thump. +- **Firebase data (opt-in):** Anonymized engine performance data is retained for quality analysis. Since this data is pseudonymous and contains no raw health values, it cannot be linked back to you after account deletion. +- **Apple Sign-In:** Your credential is stored in the Keychain and deleted if you revoke access through Apple ID settings. + +--- + +## 7. Your Rights and Choices + +You have control over your data: + +- **HealthKit permissions:** You can grant or revoke access to specific health data types at any time in Settings > Health > Thump. +- **Engine insights:** You can opt in or out of anonymized engine data collection in Thump Settings > Analytics. +- **Notifications:** You can enable or disable notifications in your device's Settings. +- **Delete your data:** Uninstalling Thump removes all locally stored data. To request deletion of any cloud-stored anonymized data, contact us at the email below. +- **Sign-In revocation:** You can revoke Sign in with Apple access at any time through Settings > Apple ID > Password & Security > Apps Using Your Apple ID. + +--- + +## 8. Children's Privacy + +Thump is not directed at children under 13. We do not knowingly collect personal information from children under 13. If you believe a child has provided us with personal information, please contact us so we can delete it. + +--- + +## 9. Security + +We implement industry-standard security measures to protect your data: + +- AES-256-GCM encryption for locally stored health data +- iOS Keychain for sensitive credentials +- SHA-256 hashing for pseudonymous identifiers +- HTTPS/TLS for all network communications +- Firebase security rules for cloud-stored data + +No method of transmission or storage is 100% secure. While we strive to protect your information, we cannot guarantee absolute security. + +--- + +## 10. International Users + +Thump processes data on your device and, if opted in, on Google Cloud servers. By using Thump, you consent to the transfer and processing of your anonymized data in the regions where Google Cloud operates. + +--- + +## 11. Changes to This Policy + +We may update this Privacy Policy from time to time. We will notify you of any material changes by updating the "Last Updated" date at the top of this policy. Your continued use of Thump after changes are posted constitutes your acceptance of the updated policy. + +--- + +## 12. Contact Us + +If you have questions about this Privacy Policy or your data, please contact us at: + +**Email:** privacy@thump.app + +--- + +*This privacy policy complies with Apple's App Store Review Guidelines (Section 5.1), HealthKit usage requirements, the EU General Data Protection Regulation (GDPR), and the California Consumer Privacy Act (CCPA).* diff --git a/apps/HeartCoach/Legal/terms-of-service.md b/apps/HeartCoach/Legal/terms-of-service.md new file mode 100644 index 00000000..367372f1 --- /dev/null +++ b/apps/HeartCoach/Legal/terms-of-service.md @@ -0,0 +1,193 @@ +# Terms of Service + +**Last Updated: March 14, 2026** + +Please read these Terms of Service ("Terms") carefully before using the Thump application ("the app," "Thump," "we," "our," or "us"). + +By downloading, installing, or using Thump, you agree to be bound by these Terms. If you do not agree, do not use the app. + +--- + +## 1. Description of Service + +Thump is a heart health and wellness application for iPhone and Apple Watch that analyzes health data from Apple HealthKit to provide wellness insights, trend analysis, readiness scores, stress assessments, and daily wellness nudges. + +--- + +## 2. Not Medical Advice + +**IMPORTANT: Thump is a wellness and fitness application. It is NOT a medical device and does NOT provide medical advice, diagnosis, or treatment.** + +- The insights, scores, and recommendations provided by Thump are for **informational and wellness purposes only**. +- Thump's algorithms analyze trends in your health data to provide general wellness guidance. These are not clinical assessments. +- **Do not use Thump as a substitute for professional medical advice.** Always consult a qualified healthcare provider for medical concerns, especially regarding heart health, abnormal symptoms, or changes in your condition. +- If you experience chest pain, shortness of breath, irregular heartbeat, or any other medical emergency, **call emergency services immediately**. Do not rely on Thump for emergency health decisions. +- Thump's anomaly alerts indicate statistical deviations from your personal baseline. They are not diagnostic indicators of any medical condition. + +--- + +## 3. Eligibility + +You must be at least 13 years old to use Thump. By using the app, you represent that you meet this age requirement. If you are under 18, you should review these Terms with a parent or guardian. + +--- + +## 4. Account and Sign-In + +Thump uses Sign in with Apple for authentication. You are responsible for maintaining the security of your Apple ID. We do not create separate accounts or store passwords. + +--- + +## 5. Launch Offer and Subscriptions + +### 5.1 First-Year Free Access + +All users who download Thump during the launch period receive **complimentary full access to all features for one (1) year** from the date of their first sign-in. No subscription or payment is required during this period. + +This includes access to all Pro and Coach tier features at no cost. You will be notified before the free period ends and given the option to subscribe to continue using premium features. + +### 5.2 Future Subscriptions + +After the one-year free period, Thump may offer paid subscription tiers with different feature access levels. Subscription details and pricing will be displayed within the app before any charges apply. You will never be charged without your explicit consent. + +### 5.3 Billing + +If you choose to subscribe after the free period, all payments are processed by Apple through the App Store. By subscribing, you agree to Apple's terms of payment. We do not process payments directly or have access to your payment information. + +### 5.4 Auto-Renewal + +Future paid subscriptions will automatically renew unless you cancel at least 24 hours before the end of the current billing period. You can manage or cancel your subscription at any time through Settings > Apple ID > Subscriptions on your device. + +### 5.5 Refunds + +Refund requests must be directed to Apple, as they process all App Store payments. We do not have the ability to issue refunds directly. + +--- + +## 6. Acceptable Use + +You agree not to: + +- Use Thump for any unlawful purpose +- Attempt to reverse-engineer, decompile, or disassemble the app +- Introduce false or misleading health data into the app +- Use the app's insights for commercial health assessments or clinical decisions +- Circumvent subscription restrictions or feature gates +- Redistribute, resell, or sublicense the app or its content + +--- + +## 7. Health Data and Privacy + +Your use of health data within Thump is governed by our [Privacy Policy](https://thump.app/privacy). Key points: + +- Health data is read from Apple HealthKit with your explicit permission +- Raw health data is stored locally on your device with encryption +- Raw health data is **never uploaded to our servers** or shared with third parties +- Anonymized engine performance data may be collected if you opt in +- Health data is never used for advertising or data mining + +--- + +## 8. Apple HealthKit Compliance + +In accordance with Apple's HealthKit guidelines: + +- We do not use HealthKit data for advertising or similar services +- We do not sell HealthKit data to advertising platforms, data brokers, or information resellers +- We do not use HealthKit data for purposes unrelated to health and fitness +- We do not store HealthKit data in iCloud +- We do not disclose HealthKit data to third parties without your explicit consent + +--- + +## 9. Intellectual Property + +All content, features, and functionality of Thump — including but not limited to algorithms, design, text, graphics, and software — are owned by us and protected by intellectual property laws. You may not copy, modify, or create derivative works based on the app. + +--- + +## 10. Disclaimer of Warranties + +**THUMP IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED.** + +To the fullest extent permitted by law, we disclaim all warranties, including but not limited to: + +- Implied warranties of merchantability, fitness for a particular purpose, and non-infringement +- Warranties that the app will be uninterrupted, error-free, or free of harmful components +- Warranties regarding the accuracy, reliability, or completeness of any wellness insights, scores, or recommendations +- Warranties that the app's algorithms will correctly identify health trends or anomalies + +Health data analysis involves inherent uncertainty. Engine scores, readiness assessments, stress levels, bio age estimates, and other outputs are statistical estimates based on available data and may not accurately reflect your actual health status. + +--- + +## 11. Limitation of Liability + +**TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THUMP, ITS DEVELOPER, OR ITS AFFILIATES BE LIABLE FOR:** + +- Any indirect, incidental, special, consequential, or punitive damages +- Any loss of profits, data, use, goodwill, or other intangible losses +- Any damages resulting from your reliance on the app's wellness insights or recommendations +- Any damages resulting from delayed, missed, or incorrect anomaly alerts or health assessments +- Any damages arising from unauthorized access to your data +- Any damages exceeding the amount you paid for the app in the 12 months preceding the claim + +**You expressly acknowledge and agree that you use Thump at your own risk.** The wellness insights provided are informational only and should not be relied upon for medical decisions. + +--- + +## 12. Indemnification + +You agree to indemnify and hold harmless Thump and its developer from any claims, damages, losses, or expenses (including legal fees) arising from: + +- Your use of the app +- Your violation of these Terms +- Your reliance on the app's wellness insights for health decisions +- Any claim by a third party related to your use of the app + +--- + +## 13. Termination + +We reserve the right to suspend or terminate your access to Thump at any time, with or without cause, and with or without notice. Upon termination: + +- Your right to use the app ceases immediately +- Locally stored data remains on your device until you uninstall the app +- Active subscriptions should be cancelled through Apple to avoid further charges + +--- + +## 14. Changes to These Terms + +We may modify these Terms at any time. We will notify you of material changes by updating the "Last Updated" date. Your continued use of Thump after changes are posted constitutes acceptance of the revised Terms. + +--- + +## 15. Governing Law + +These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which the developer resides, without regard to conflict of law principles. + +--- + +## 16. Severability + +If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in full force and effect. + +--- + +## 17. Entire Agreement + +These Terms, together with our Privacy Policy, constitute the entire agreement between you and Thump regarding your use of the app. + +--- + +## 18. Contact Us + +If you have questions about these Terms, please contact us at: + +**Email:** legal@thump.app + +--- + +*These terms comply with Apple's App Store Review Guidelines and the Apple Developer Program License Agreement requirements for health and fitness applications.* diff --git a/apps/HeartCoach/Shared/Models/HeartModels.swift b/apps/HeartCoach/Shared/Models/HeartModels.swift index 5e0ba841..dd5f2697 100644 --- a/apps/HeartCoach/Shared/Models/HeartModels.swift +++ b/apps/HeartCoach/Shared/Models/HeartModels.swift @@ -1412,6 +1412,10 @@ public struct UserProfile: Codable, Equatable, Sendable { /// Email address from Sign in with Apple (optional, only provided on first sign-in). public var email: String? + /// Date when the launch free year started (first sign-in). + /// Nil if the user signed up after the launch promotion ends. + public var launchFreeStartDate: Date? + public init( displayName: String = "", joinDate: Date = Date(), @@ -1421,7 +1425,8 @@ public struct UserProfile: Codable, Equatable, Sendable { nudgeCompletionDates: Set = [], dateOfBirth: Date? = nil, biologicalSex: BiologicalSex = .notSet, - email: String? = nil + email: String? = nil, + launchFreeStartDate: Date? = nil ) { self.displayName = displayName self.joinDate = joinDate @@ -1432,6 +1437,7 @@ public struct UserProfile: Codable, Equatable, Sendable { self.dateOfBirth = dateOfBirth self.biologicalSex = biologicalSex self.email = email + self.launchFreeStartDate = launchFreeStartDate } /// Computed chronological age in years from date of birth. @@ -1440,6 +1446,21 @@ public struct UserProfile: Codable, Equatable, Sendable { let components = Calendar.current.dateComponents([.year], from: dob, to: Date()) return components.year } + + /// Whether the user is currently within the launch free year. + public var isInLaunchFreeYear: Bool { + guard let start = launchFreeStartDate else { return false } + guard let expiryDate = Calendar.current.date(byAdding: .year, value: 1, to: start) else { return false } + return Date() < expiryDate + } + + /// Days remaining in the launch free year. Returns 0 if expired or not enrolled. + public var launchFreeDaysRemaining: Int { + guard let start = launchFreeStartDate else { return 0 } + guard let expiryDate = Calendar.current.date(byAdding: .year, value: 1, to: start) else { return 0 } + let days = Calendar.current.dateComponents([.day], from: Date(), to: expiryDate).day ?? 0 + return max(0, days) + } } // MARK: - Subscription Tier diff --git a/apps/HeartCoach/Tests/FirestoreTelemetryIntegrationTests.swift b/apps/HeartCoach/Tests/FirestoreTelemetryIntegrationTests.swift new file mode 100644 index 00000000..92841332 --- /dev/null +++ b/apps/HeartCoach/Tests/FirestoreTelemetryIntegrationTests.swift @@ -0,0 +1,358 @@ +// FirestoreTelemetryIntegrationTests.swift +// ThumpTests +// +// End-to-end integration test that feeds mock health metrics through +// the full 9-engine pipeline, uploads a PipelineTrace to Firestore, +// then reads it back to validate the data landed correctly. +// +// Requires: GoogleService-Info.plist in the app bundle and a Firestore +// database in test mode. +// Platforms: iOS 17+ + +import XCTest +import FirebaseCore +import FirebaseFirestore +@testable import Thump + +// MARK: - Firestore Telemetry Integration Tests + +/// Runs the full engine pipeline with mock health data, uploads a +/// PipelineTrace to Firestore, then reads it back and validates +/// every engine's data is present and correct. +@MainActor +final class FirestoreTelemetryIntegrationTests: XCTestCase { + + /// Fixed test user ID so traces are easy to find in the console. + private let testUserId = "test-telemetry-user" + + private var defaults: UserDefaults? + private var localStore: LocalStore? + private var db: Firestore! + + override func setUp() { + super.setUp() + if FirebaseApp.app() == nil { + FirebaseApp.configure() + } + db = Firestore.firestore() + defaults = UserDefaults(suiteName: "com.thump.telemetry-test.\(UUID().uuidString)") + localStore = defaults.map { LocalStore(defaults: $0) } + } + + override func tearDown() { + defaults = nil + localStore = nil + db = nil + super.tearDown() + } + + // MARK: - Full Pipeline → Upload → Read Back → Validate + + /// Runs mock health data through all 9 engines, uploads the trace + /// to Firestore, reads it back, and validates every field. + func testFullPipelineUploadsAndReadsBackFromFirestore() async throws { + let localStore = try XCTUnwrap(localStore) + + // Set date of birth so BioAge engine runs + localStore.profile.dateOfBirth = Calendar.current.date( + byAdding: .year, value: -35, to: Date() + ) + localStore.saveProfile() + + // 21 days of mock history + today + let history = MockData.mockHistory(days: 21) + let today = MockData.mockTodaySnapshot + + let provider = MockHealthDataProvider( + todaySnapshot: today, + history: history, + shouldAuthorize: true + ) + + let viewModel = DashboardViewModel( + healthKitService: provider, + localStore: localStore + ) + + // Run the full pipeline + await viewModel.refresh() + + // Verify all engines produced output locally + let assessment = try XCTUnwrap(viewModel.assessment, "HeartTrendEngine failed") + let stress = try XCTUnwrap(viewModel.stressResult, "StressEngine failed") + let readiness = try XCTUnwrap(viewModel.readinessResult, "ReadinessEngine failed") + let coaching = try XCTUnwrap(viewModel.coachingReport, "CoachingEngine failed") + let zones = try XCTUnwrap(viewModel.zoneAnalysis, "ZoneAnalysis failed") + let buddies = try XCTUnwrap(viewModel.buddyRecommendations, "BuddyEngine failed") + XCTAssertNil(viewModel.errorMessage, "Pipeline error: \(viewModel.errorMessage ?? "")") + + // Build trace + var trace = PipelineTrace( + timestamp: Date(), + pipelineDurationMs: 42.0, + historyDays: history.count + ) + trace.heartTrend = HeartTrendTrace(from: assessment, durationMs: 10) + trace.stress = StressTrace(from: stress, durationMs: 5) + trace.readiness = ReadinessTrace(from: readiness, durationMs: 3) + if let bioAge = viewModel.bioAgeResult { + trace.bioAge = BioAgeTrace(from: bioAge, durationMs: 2) + } + trace.coaching = CoachingTrace(from: coaching, durationMs: 4) + trace.zoneAnalysis = ZoneAnalysisTrace(from: zones, durationMs: 1) + trace.buddy = BuddyTrace(from: buddies, durationMs: 6) + + // Upload to Firestore + let docData = trace.toFirestoreData() + let collectionRef = db.collection("users") + .document(testUserId) + .collection("traces") + + let uploadExp = expectation(description: "Firestore upload") + var uploadedDocId: String? + + collectionRef.addDocument(data: docData) { error in + XCTAssertNil(error, "Upload failed: \(error?.localizedDescription ?? "")") + uploadExp.fulfill() + } + + await fulfillment(of: [uploadExp], timeout: 15.0) + + // Read back the most recent trace + let readExp = expectation(description: "Firestore read-back") + var readDoc: [String: Any]? + + collectionRef + .order(by: "timestamp", descending: true) + .limit(to: 1) + .getDocuments { snapshot, error in + XCTAssertNil(error, "Read failed: \(error?.localizedDescription ?? "")") + readDoc = snapshot?.documents.first?.data() + uploadedDocId = snapshot?.documents.first?.documentID + readExp.fulfill() + } + + await fulfillment(of: [readExp], timeout: 15.0) + + let doc = try XCTUnwrap(readDoc, "No document found in Firestore") + + // MARK: Validate top-level fields + XCTAssertEqual(doc["pipelineDurationMs"] as? Double, 42.0) + XCTAssertEqual(doc["historyDays"] as? Int, history.count) + XCTAssertNotNil(doc["appVersion"] as? String) + XCTAssertNotNil(doc["buildNumber"] as? String) + XCTAssertNotNil(doc["deviceModel"] as? String) + + // MARK: Validate HeartTrend + let ht = try XCTUnwrap(doc["heartTrend"] as? [String: Any], "heartTrend missing") + XCTAssertEqual(ht["status"] as? String, assessment.status.rawValue) + XCTAssertEqual(ht["confidence"] as? String, assessment.confidence.rawValue) + XCTAssertNotNil(ht["anomalyScore"] as? Double) + XCTAssertNotNil(ht["regressionFlag"] as? Bool) + XCTAssertNotNil(ht["stressFlag"] as? Bool) + XCTAssertEqual(ht["durationMs"] as? Double, 10) + print(" ✅ heartTrend: status=\(ht["status"] ?? ""), confidence=\(ht["confidence"] ?? "")") + + // MARK: Validate Stress + let st = try XCTUnwrap(doc["stress"] as? [String: Any], "stress missing") + XCTAssertEqual(st["score"] as? Double, stress.score) + XCTAssertEqual(st["level"] as? String, stress.level.rawValue) + XCTAssertEqual(st["mode"] as? String, stress.mode.rawValue) + XCTAssertEqual(st["confidence"] as? String, stress.confidence.rawValue) + print(" ✅ stress: score=\(st["score"] ?? ""), level=\(st["level"] ?? "")") + + // MARK: Validate Readiness + let rd = try XCTUnwrap(doc["readiness"] as? [String: Any], "readiness missing") + XCTAssertEqual(rd["score"] as? Int, readiness.score) + XCTAssertEqual(rd["level"] as? String, readiness.level.rawValue) + XCTAssertNotNil(rd["pillarScores"] as? [String: Any]) + print(" ✅ readiness: score=\(rd["score"] ?? ""), level=\(rd["level"] ?? "")") + + // MARK: Validate BioAge (optional — depends on date of birth) + if let bioAge = viewModel.bioAgeResult { + let ba = try XCTUnwrap(doc["bioAge"] as? [String: Any], "bioAge missing") + XCTAssertEqual(ba["bioAge"] as? Int, bioAge.bioAge) + XCTAssertEqual(ba["chronologicalAge"] as? Int, bioAge.chronologicalAge) + XCTAssertEqual(ba["difference"] as? Int, bioAge.difference) + XCTAssertEqual(ba["category"] as? String, bioAge.category.rawValue) + print(" ✅ bioAge: \(ba["bioAge"] ?? "")y (chrono=\(ba["chronologicalAge"] ?? ""))") + } + + // MARK: Validate Coaching + let co = try XCTUnwrap(doc["coaching"] as? [String: Any], "coaching missing") + XCTAssertEqual(co["weeklyProgressScore"] as? Int, coaching.weeklyProgressScore) + XCTAssertNotNil(co["insightCount"] as? Int) + XCTAssertNotNil(co["streakDays"] as? Int) + print(" ✅ coaching: progress=\(co["weeklyProgressScore"] ?? ""), insights=\(co["insightCount"] ?? "")") + + // MARK: Validate ZoneAnalysis + let za = try XCTUnwrap(doc["zoneAnalysis"] as? [String: Any], "zoneAnalysis missing") + XCTAssertEqual(za["overallScore"] as? Int, zones.overallScore) + XCTAssertNotNil(za["pillarCount"] as? Int) + XCTAssertNotNil(za["hasRecommendation"] as? Bool) + print(" ✅ zoneAnalysis: score=\(za["overallScore"] ?? ""), pillars=\(za["pillarCount"] ?? "")") + + // MARK: Validate Buddy + let bu = try XCTUnwrap(doc["buddy"] as? [String: Any], "buddy missing") + XCTAssertEqual(bu["count"] as? Int, buddies.count) + XCTAssertNotNil(bu["durationMs"] as? Double) + print(" ✅ buddy: count=\(bu["count"] ?? ""), topPriority=\(bu["topPriority"] ?? "none")") + + print("\n✅ Full pipeline trace validated in Firestore!") + print(" Document: users/\(testUserId)/traces/\(uploadedDocId ?? "?")") + } + + // MARK: - All Personas → Upload → Read Back + + /// Runs every synthetic persona through the pipeline, uploads + /// traces, then reads them all back and validates each one. + func testAllPersonasUploadAndReadBackFromFirestore() async throws { + let personas: [MockData.Persona] = [ + .athleticMale, .athleticFemale, + .normalMale, .normalFemale, + .couchPotatoMale, .couchPotatoFemale, + .overweightMale, .overweightFemale, + .underwieghtFemale, + .seniorActive + ] + + let collectionRef = db.collection("users") + .document(testUserId) + .collection("persona-traces") + + var uploadedCount = 0 + + for persona in personas { + let personaDefaults = UserDefaults( + suiteName: "com.thump.persona-test.\(UUID().uuidString)" + )! + let store = LocalStore(defaults: personaDefaults) + store.profile.dateOfBirth = Calendar.current.date( + byAdding: .year, value: -35, to: Date() + ) + store.saveProfile() + + let history = MockData.personaHistory(persona, days: 21) + guard let today = history.last else { continue } + + let provider = MockHealthDataProvider( + todaySnapshot: today, + history: Array(history.dropLast()), + shouldAuthorize: true + ) + + let viewModel = DashboardViewModel( + healthKitService: provider, + localStore: store + ) + + await viewModel.refresh() + + let assessment = try XCTUnwrap(viewModel.assessment, "\(persona) assessment nil") + + // Build trace + var trace = PipelineTrace( + timestamp: Date(), + pipelineDurationMs: 0, + historyDays: history.count + ) + trace.heartTrend = HeartTrendTrace(from: assessment, durationMs: 0) + if let s = viewModel.stressResult { + trace.stress = StressTrace(from: s, durationMs: 0) + } + if let r = viewModel.readinessResult { + trace.readiness = ReadinessTrace(from: r, durationMs: 0) + } + if let b = viewModel.bioAgeResult { + trace.bioAge = BioAgeTrace(from: b, durationMs: 0) + } + if let c = viewModel.coachingReport { + trace.coaching = CoachingTrace(from: c, durationMs: 0) + } + if let z = viewModel.zoneAnalysis { + trace.zoneAnalysis = ZoneAnalysisTrace(from: z, durationMs: 0) + } + if let recs = viewModel.buddyRecommendations { + trace.buddy = BuddyTrace(from: recs, durationMs: 0) + } + + // Upload with persona name + let personaName = String(describing: persona) + let docData = trace.toFirestoreData().merging( + ["persona": personaName], + uniquingKeysWith: { _, new in new } + ) + + let uploadExp = expectation(description: "Upload \(personaName)") + collectionRef.addDocument(data: docData) { error in + XCTAssertNil(error, "\(personaName) upload failed: \(error?.localizedDescription ?? "")") + uploadExp.fulfill() + } + await fulfillment(of: [uploadExp], timeout: 15.0) + uploadedCount += 1 + print(" ✅ \(personaName) uploaded") + } + + // Read back ALL persona traces and validate + let readExp = expectation(description: "Read all persona traces") + var readDocs: [QueryDocumentSnapshot] = [] + + collectionRef + .order(by: "timestamp", descending: true) + .limit(to: uploadedCount) + .getDocuments { snapshot, error in + XCTAssertNil(error, "Read-back failed: \(error?.localizedDescription ?? "")") + readDocs = snapshot?.documents ?? [] + readExp.fulfill() + } + + await fulfillment(of: [readExp], timeout: 15.0) + + XCTAssertGreaterThanOrEqual(readDocs.count, uploadedCount, + "Expected \(uploadedCount) persona traces, found \(readDocs.count)") + + // Validate each read-back document has required engine fields + for doc in readDocs { + let data = doc.data() + let persona = data["persona"] as? String ?? "unknown" + + // Every trace must have heartTrend (primary engine) + let ht = try XCTUnwrap(data["heartTrend"] as? [String: Any], + "\(persona): heartTrend missing") + XCTAssertNotNil(ht["status"], "\(persona): heartTrend.status missing") + XCTAssertNotNil(ht["confidence"], "\(persona): heartTrend.confidence missing") + XCTAssertNotNil(ht["anomalyScore"], "\(persona): heartTrend.anomalyScore missing") + + // Stress (optional — some personas may not produce stress) + let stressScore: Any + if let st = data["stress"] as? [String: Any] { + XCTAssertNotNil(st["score"], "\(persona): stress.score missing") + XCTAssertNotNil(st["level"], "\(persona): stress.level missing") + stressScore = st["score"] ?? "nil" + } else { + stressScore = "n/a" + } + + // Readiness (optional — some personas may not produce readiness) + let readinessScore: Any + if let rd = data["readiness"] as? [String: Any] { + XCTAssertNotNil(rd["score"], "\(persona): readiness.score missing") + readinessScore = rd["score"] ?? "nil" + } else { + readinessScore = "n/a" + } + + // Metadata + XCTAssertNotNil(data["appVersion"], "\(persona): appVersion missing") + XCTAssertNotNil(data["deviceModel"], "\(persona): deviceModel missing") + + print(" ✅ \(persona) read-back validated: " + + "status=\(ht["status"] ?? ""), " + + "stress=\(stressScore), " + + "readiness=\(readinessScore)") + } + + print("\n📊 All \(readDocs.count) persona traces validated in Firestore!") + print(" Collection: users/\(testUserId)/persona-traces/") + } +} diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift index 9272cbed..c9766504 100644 --- a/apps/HeartCoach/iOS/ThumpiOSApp.swift +++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift @@ -78,6 +78,9 @@ struct ThumpiOSApp: App { /// Tracks whether the user has accepted the Terms of Service and Privacy Policy. @AppStorage("thump_legal_accepted_v1") private var legalAccepted: Bool = false + /// Whether to show the launch congratulations screen after first sign-in. + @AppStorage("thump_launch_congrats_shown") private var launchCongratsShown: Bool = false + // MARK: - Root View Routing /// Routes through: Sign In → Legal Gate → Onboarding → Main Tab View. @@ -98,8 +101,17 @@ struct ThumpiOSApp: App { MainTabView() } else if !isSignedIn { AppleSignInView { + // Record launch free year start date on first sign-in + if localStore.profile.launchFreeStartDate == nil { + localStore.profile.launchFreeStartDate = Date() + localStore.saveProfile() + } isSignedIn = true } + } else if !launchCongratsShown && localStore.profile.isInLaunchFreeYear { + LaunchCongratsView { + launchCongratsShown = true + } } else if !legalAccepted { LegalGateView { legalAccepted = true @@ -153,17 +165,25 @@ struct ThumpiOSApp: App { // Sync subscription tier to local store await MainActor.run { - #if targetEnvironment(simulator) - // Force Coach tier in the simulator for full feature access during development - subscriptionService.currentTier = .coach - localStore.tier = .coach - localStore.saveTier() - #else - if subscriptionService.currentTier != localStore.tier { - localStore.tier = subscriptionService.currentTier + if localStore.profile.isInLaunchFreeYear { + // Launch promotion: grant full Coach access for the first year + subscriptionService.currentTier = .coach + localStore.tier = .coach localStore.saveTier() + AppLogger.info("Launch free year active — Coach tier granted (\(localStore.profile.launchFreeDaysRemaining) days remaining)") + } else { + #if targetEnvironment(simulator) + // Force Coach tier in the simulator for full feature access during development + subscriptionService.currentTier = .coach + localStore.tier = .coach + localStore.saveTier() + #else + if subscriptionService.currentTier != localStore.tier { + localStore.tier = subscriptionService.currentTier + localStore.saveTier() + } + #endif } - #endif } let elapsed = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 diff --git a/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift b/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift new file mode 100644 index 00000000..d685f830 --- /dev/null +++ b/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift @@ -0,0 +1,144 @@ +// LaunchCongratsView.swift +// Thump iOS +// +// Congratulations screen shown after first sign-in to inform the user +// they have one year of free full access to all features. +// Platforms: iOS 17+ + +import SwiftUI + +// MARK: - Launch Congratulations View + +/// Full-screen congratulations view shown once after the user's first +/// sign-in, informing them of the one-year free Coach access. +struct LaunchCongratsView: View { + + /// Called when the user taps "Get Started" to dismiss and continue. + let onContinue: () -> Void + + // MARK: - Animation State + + @State private var showContent = false + @State private var showButton = false + + // MARK: - Body + + var body: some View { + ZStack { + // Gradient background + LinearGradient( + colors: [ + Color.pink.opacity(0.15), + Color.purple.opacity(0.1), + Color(.systemBackground) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 32) { + Spacer() + + // Gift icon + Image(systemName: "gift.fill") + .font(.system(size: 72)) + .foregroundStyle( + LinearGradient( + colors: [.pink, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .scaleEffect(showContent ? 1.0 : 0.5) + .opacity(showContent ? 1 : 0) + + VStack(spacing: 16) { + Text("Congratulations!") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("1 Year Free Access") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle( + LinearGradient( + colors: [.pink, .purple], + startPoint: .leading, + endPoint: .trailing + ) + ) + + Text("You have full access to every Thump feature for one year — completely free. No subscription required.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .opacity(showContent ? 1 : 0) + .offset(y: showContent ? 0 : 20) + + // Feature highlights + VStack(alignment: .leading, spacing: 14) { + featureRow(icon: "heart.text.clipboard.fill", text: "Heart trend analysis & anomaly alerts") + featureRow(icon: "brain.head.profile.fill", text: "Stress, readiness & bio age engines") + featureRow(icon: "figure.run", text: "Coaching insights & zone analysis") + featureRow(icon: "bell.badge.fill", text: "Smart wellness nudges") + } + .padding(.horizontal, 40) + .opacity(showContent ? 1 : 0) + .offset(y: showContent ? 0 : 20) + + Spacer() + + // Get Started button + Button { + onContinue() + } label: { + Text("Get Started") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + LinearGradient( + colors: [.pink, .purple], + startPoint: .leading, + endPoint: .trailing + ), + in: RoundedRectangle(cornerRadius: 16) + ) + } + .padding(.horizontal, 32) + .opacity(showButton ? 1 : 0) + .offset(y: showButton ? 0 : 20) + + Spacer() + .frame(height: 40) + } + } + .onAppear { + withAnimation(.easeOut(duration: 0.6)) { + showContent = true + } + withAnimation(.easeOut(duration: 0.5).delay(0.4)) { + showButton = true + } + } + } + + // MARK: - Feature Row + + private func featureRow(icon: String, text: String) -> some View { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.pink) + .frame(width: 28) + Text(text) + .font(.subheadline) + .foregroundStyle(.primary) + } + } +} diff --git a/apps/HeartCoach/iOS/Views/SettingsView.swift b/apps/HeartCoach/iOS/Views/SettingsView.swift index 88b4887b..6555a902 100644 --- a/apps/HeartCoach/iOS/Views/SettingsView.swift +++ b/apps/HeartCoach/iOS/Views/SettingsView.swift @@ -184,31 +184,59 @@ struct SettingsView: View { private var subscriptionSection: some View { Section { - HStack { - Label("Current Plan", systemImage: "creditcard.fill") - Spacer() - Text(currentTierDisplayName) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.pink) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(Color.pink.opacity(0.12), in: Capsule()) - } + if localStore.profile.isInLaunchFreeYear { + // Launch free year — show status instead of paywall + HStack { + Label("Current Plan", systemImage: "gift.fill") + Spacer() + Text("Coach (Free)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.green) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.green.opacity(0.12), in: Capsule()) + } - Button { - InteractionLog.log(.buttonTap, element: "upgrade_button", page: "Settings") - showPaywall = true - } label: { HStack { - Label("Upgrade Plan", systemImage: "arrow.up.circle.fill") + Label("Free Access", systemImage: "clock.fill") Spacer() - Image(systemName: "chevron.right") - .font(.caption) + Text("\(localStore.profile.launchFreeDaysRemaining) days remaining") + .font(.subheadline) .foregroundStyle(.secondary) } + + Text("All features are unlocked for your first year. No payment required.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + // Free year expired or not enrolled — show regular subscription UI + HStack { + Label("Current Plan", systemImage: "creditcard.fill") + Spacer() + Text(currentTierDisplayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.pink) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.pink.opacity(0.12), in: Capsule()) + } + + Button { + InteractionLog.log(.buttonTap, element: "upgrade_button", page: "Settings") + showPaywall = true + } label: { + HStack { + Label("Upgrade Plan", systemImage: "arrow.up.circle.fill") + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("settings_upgrade_button") } - .accessibilityIdentifier("settings_upgrade_button") } header: { Text("Subscription") }