diff --git a/apps/HeartCoach/CUSTOMER_VALUE_PLAN.md b/apps/HeartCoach/CUSTOMER_VALUE_PLAN.md new file mode 100644 index 00000000..7f25926b --- /dev/null +++ b/apps/HeartCoach/CUSTOMER_VALUE_PLAN.md @@ -0,0 +1,136 @@ +# Thump Customer Value Plan + +## Honest Product Read + +Thump is promising more than it has proven. + +Right now the strongest customer value is not "AI wellness" or "family heart platform." It is one narrow job: + +- help me decide whether today should be a push day, a steady day, or a recovery day + +That is the habit worth building around. + +What is weak today: + +- too much product breadth for the current depth +- trust asks happen before value is obvious +- monetization existed in code before it existed in the customer story +- family and multi-tier packaging overstate what is actually useful today + +## Most Useful Feature For Customers + +The most useful feature is the morning readiness decision: + +- one quick snapshot +- one plain-language recommendation +- one reason why + +The customer does not primarily want "more metrics." +The customer wants: + +- confidence about how hard to train today +- a clear signal when recovery is slipping +- a simple weekly recap that helps them adjust behavior + +## Keep / Cut / Delay + +### Keep + +- daily readiness / recovery snapshot +- plain-language coaching recommendation +- stress context when it changes today's recommendation +- weekly review +- PDF summary for users who want to share progress + +### Cut From Marketing + +- Family plan positioning +- caregiver claims +- broad "full insights" language with no concrete result +- any implication that the app is a medical evaluator + +### Delay + +- family dashboard +- caregiver workflows +- advanced multi-member features +- broad subscription ladders + +## Customer Journey To Build Around + +### First 60 Seconds + +The customer should be able to answer: + +- what is my state today? +- what should I do with that information? + +### First Week + +The customer should build a habit: + +- open every morning +- check the recommendation +- notice one useful pattern + +### First Month + +The customer should feel: + +- this catches overreaching earlier than I do +- this helps me train more consistently +- this is worth paying for because it changes decisions + +## Product Strategy + +### Free + +- daily snapshot +- basic trends +- watch check-ins + +### Coach + +- full metrics dashboard +- stress and anomaly context +- weekly review +- deeper multi-week trends +- PDF wellness summaries + +## 30 / 60 / 90 Day Build Plan + +### First 30 Days + +- make the daily snapshot the clearest part of the app +- tighten sign-in and trust copy +- remove stale marketing around Family and legacy pricing +- add paywall triggers at real premium boundaries + +### Next 30 Days + +- improve weekly review so it feels worth paying for +- validate restore purchases and billing flows +- instrument funnel metrics: snapshot completion, paywall views, trial starts, conversion + +### Final 30 Days + +- run TestFlight with real watch users +- tune onboarding based on permission drop-off +- ship only after retention on the morning-check habit is real + +## Success Metrics + +- install to first snapshot completion +- HealthKit grant rate +- percentage of users opening the app at least 4 mornings per week +- weekly review open rate +- paywall view to trial start +- trial to paid conversion + +## Rule For Future Features + +Every new feature should answer one question: + +- does this make the daily decision clearer, more trusted, or more actionable? + +If not, it is probably not a priority yet. diff --git a/apps/HeartCoach/Legal/privacy-policy.md b/apps/HeartCoach/Legal/privacy-policy.md index 83f9452c..0823056e 100644 --- a/apps/HeartCoach/Legal/privacy-policy.md +++ b/apps/HeartCoach/Legal/privacy-policy.md @@ -36,7 +36,7 @@ When you sign in with Apple, we receive an anonymous, app-specific identifier is ### 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. +Some users may have grandfathered launch access for a limited period. If you choose to subscribe, 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) diff --git a/apps/HeartCoach/Legal/terms-of-service.md b/apps/HeartCoach/Legal/terms-of-service.md index 367372f1..8781046f 100644 --- a/apps/HeartCoach/Legal/terms-of-service.md +++ b/apps/HeartCoach/Legal/terms-of-service.md @@ -38,17 +38,17 @@ Thump uses Sign in with Apple for authentication. You are responsible for mainta --- -## 5. Launch Offer and Subscriptions +## 5. Launch Access and Subscriptions -### 5.1 First-Year Free Access +### 5.1 Grandfathered Launch 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. +Some users who joined Thump during the original launch period may retain **complimentary Coach access for one (1) year** from the date their launch access began. This launch access is grandfathered and is not guaranteed for all new users. -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. +If you have grandfathered launch access, you will be notified before it 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. +Thump may offer paid subscriptions, including the Coach plan currently presented in the app. 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 diff --git a/apps/HeartCoach/PRODUCTION_TODO.md b/apps/HeartCoach/PRODUCTION_TODO.md new file mode 100644 index 00000000..9493fd07 --- /dev/null +++ b/apps/HeartCoach/PRODUCTION_TODO.md @@ -0,0 +1,45 @@ +# Thump Production TODO + +## Pricing And Packaging + +- [x] Reduce the public subscription surface to `Free + Coach`. +- [x] Set Coach pricing in code to `$2.99/month`. +- [x] Set Coach annual pricing in code to `$17.99/year` to land at about 50% off monthly billing. +- [ ] Mirror the same price points in App Store Connect product configuration. +- [ ] Remove or archive legacy `Pro` and `Family` products after legacy subscribers are migrated or retired. + +## Monetization Enforcement + +- [x] Stop auto-enrolling every new user into launch-year free access. +- [x] Preserve grandfathered launch access for users who already have it. +- [ ] Wire `SubscriptionTier` feature gates into the actual iOS views and flows. +- [ ] Decide exactly which free features remain available without Coach. +- [ ] Add paywall entry points at the feature boundaries, not only from Settings. + +## Product Copy And Trust + +- [x] Update Settings subscription copy so the app no longer says everything is free. +- [x] Update the paywall to sell one Coach plan instead of three tiers. +- [x] Update launch-access copy to make it clear that complimentary access is grandfathered. +- [ ] Rewrite onboarding trust copy so it does not over-promise on-device-only behavior. +- [ ] Align website marketing copy with the in-app promise and pricing. + +## Legal And Compliance + +- [x] Update markdown legal docs to stop promising a free first year to all new users. +- [x] Update in-app legal text to reflect the single Coach offering. +- [ ] Review website legal pages for any stale launch-offer or pricing language. +- [ ] Re-check App Store privacy nutrition labels against current Firebase, telemetry, and bug-report behavior. + +## Technical Readiness + +- [ ] Create and test a StoreKit configuration file for local subscription testing. +- [ ] Remove simulator-only Coach auto-grant before release builds are finalized. +- [ ] Add end-to-end tests that verify free users hit the intended paywall gates. +- [ ] Verify restore-purchase and cancellation UX on device and in TestFlight. + +## Launch Readiness + +- [ ] Update App Store screenshots and website pricing section to match `Free + Coach`. +- [ ] Add conversion analytics for paywall view, trial start, purchase, restore, and cancellation. +- [ ] Run TestFlight with real Apple Watch users before spending on acquisition. diff --git a/apps/HeartCoach/Shared/Models/UserModels.swift b/apps/HeartCoach/Shared/Models/UserModels.swift index d60af6d7..ae7bcf13 100644 --- a/apps/HeartCoach/Shared/Models/UserModels.swift +++ b/apps/HeartCoach/Shared/Models/UserModels.swift @@ -220,7 +220,7 @@ 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). + /// Date when grandfathered launch access started. /// Nil if the user signed up after the launch promotion ends. public var launchFreeStartDate: Date? @@ -323,14 +323,14 @@ public struct UserProfile: Codable, Equatable, Sendable { steadyStreakDays >= 14 } - /// Whether the user is currently within the launch free year. + /// Whether the user is currently within the grandfathered launch 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. + /// Days remaining in the grandfathered launch 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 } @@ -348,6 +348,9 @@ public enum SubscriptionTier: String, Codable, Equatable, Sendable, CaseIterable case coach case family + /// The only tier currently sold to new subscribers. + public static let merchandisedTier: SubscriptionTier = .coach + /// User-facing tier name. public var displayName: String { switch self { @@ -363,7 +366,7 @@ public enum SubscriptionTier: String, Codable, Equatable, Sendable, CaseIterable switch self { case .free: return 0.0 case .pro: return 3.99 - case .coach: return 6.99 + case .coach: return 2.99 case .family: return 0.0 // Family is annual-only } } @@ -373,7 +376,7 @@ public enum SubscriptionTier: String, Codable, Equatable, Sendable, CaseIterable switch self { case .free: return 0.0 case .pro: return 29.99 - case .coach: return 59.99 + case .coach: return 17.99 case .family: return 79.99 } } @@ -398,7 +401,9 @@ public enum SubscriptionTier: String, Codable, Equatable, Sendable, CaseIterable ] case .coach: return [ - "Everything in Pro", + "Full wellness dashboard (HRV, Recovery, VO2, zone activity)", + "Personalized daily suggestions and nudges", + "Stress pattern awareness and anomaly alerts", "Weekly wellness review and gentle plan tweaks", "Multi-week trend exploration and progress snapshots", "Shareable PDF wellness summaries", @@ -414,26 +419,42 @@ public enum SubscriptionTier: String, Codable, Equatable, Sendable, CaseIterable } /// Whether this tier grants access to full metric dashboards. - /// NOTE: All features are currently free for all users. public var canAccessFullMetrics: Bool { - return true + switch self { + case .free: + return false + case .pro, .coach, .family: + return true + } } /// Whether this tier grants access to personalized nudges. - /// NOTE: All features are currently free for all users. public var canAccessNudges: Bool { - return true + switch self { + case .free: + return false + case .pro, .coach, .family: + return true + } } /// Whether this tier grants access to weekly reports and trend analysis. - /// NOTE: All features are currently free for all users. public var canAccessReports: Bool { - return true + switch self { + case .coach, .family: + return true + case .free, .pro: + return false + } } /// Whether this tier grants access to activity-trend correlation analysis. - /// NOTE: All features are currently free for all users. public var canAccessCorrelations: Bool { - return true + switch self { + case .free: + return false + case .pro, .coach, .family: + return true + } } } diff --git a/apps/HeartCoach/Tests/ClickableDataFlowTests.swift b/apps/HeartCoach/Tests/ClickableDataFlowTests.swift index 53d54c65..c45b63a8 100644 --- a/apps/HeartCoach/Tests/ClickableDataFlowTests.swift +++ b/apps/HeartCoach/Tests/ClickableDataFlowTests.swift @@ -1394,7 +1394,7 @@ final class SettingsOnboardingDataFlowTests: XCTestCase { // MARK: - Profile: Launch Free Year func testLaunchFreeYear_showsCorrectPlan() { - // When isInLaunchFreeYear is true, subscription section shows "Coach (Free)" + // Grandfathered launch users should still report an active complimentary plan. let isInFreeYear = localStore.profile.isInLaunchFreeYear // Just verify the property is accessible and returns a boolean XCTAssertTrue(isInFreeYear == true || isInFreeYear == false) diff --git a/apps/HeartCoach/Tests/ConfigServiceTests.swift b/apps/HeartCoach/Tests/ConfigServiceTests.swift index 7c6893f8..7120140e 100644 --- a/apps/HeartCoach/Tests/ConfigServiceTests.swift +++ b/apps/HeartCoach/Tests/ConfigServiceTests.swift @@ -78,30 +78,30 @@ final class ConfigServiceTests: XCTestCase { ) } - // MARK: - Test: All Tiers Can Access All Features (all features are free) + // MARK: - Test: Tier-Based Feature Access - func testFreeTierCanAccessAllFeatures() { - XCTAssertTrue(ConfigService.canAccessFullMetrics(tier: .free)) - XCTAssertTrue(ConfigService.canAccessNudges(tier: .free)) - XCTAssertTrue(ConfigService.canAccessReports(tier: .free)) - XCTAssertTrue(ConfigService.canAccessCorrelations(tier: .free)) + func testFreeTierCanAccessOnlyStarterFeatures() { + XCTAssertFalse(ConfigService.canAccessFullMetrics(tier: .free)) + XCTAssertFalse(ConfigService.canAccessNudges(tier: .free)) + XCTAssertFalse(ConfigService.canAccessReports(tier: .free)) + XCTAssertFalse(ConfigService.canAccessCorrelations(tier: .free)) } - func testProTierCanAccessAllFeatures() { + func testProTierCanAccessCorePremiumFeatures() { XCTAssertTrue(ConfigService.canAccessFullMetrics(tier: .pro)) XCTAssertTrue(ConfigService.canAccessNudges(tier: .pro)) - XCTAssertTrue(ConfigService.canAccessReports(tier: .pro)) + XCTAssertFalse(ConfigService.canAccessReports(tier: .pro)) XCTAssertTrue(ConfigService.canAccessCorrelations(tier: .pro)) } - func testCoachTierCanAccessAllFeatures() { + func testCoachTierCanAccessAllPremiumFeatures() { XCTAssertTrue(ConfigService.canAccessFullMetrics(tier: .coach)) XCTAssertTrue(ConfigService.canAccessNudges(tier: .coach)) XCTAssertTrue(ConfigService.canAccessReports(tier: .coach)) XCTAssertTrue(ConfigService.canAccessCorrelations(tier: .coach)) } - func testFamilyTierCanAccessAllFeatures() { + func testFamilyTierCanAccessAllPremiumFeatures() { XCTAssertTrue(ConfigService.canAccessFullMetrics(tier: .family)) XCTAssertTrue(ConfigService.canAccessNudges(tier: .family)) XCTAssertTrue(ConfigService.canAccessReports(tier: .family)) diff --git a/apps/HeartCoach/Tests/RubricV2CoverageTests.swift b/apps/HeartCoach/Tests/RubricV2CoverageTests.swift index af123e72..dab84233 100644 --- a/apps/HeartCoach/Tests/RubricV2CoverageTests.swift +++ b/apps/HeartCoach/Tests/RubricV2CoverageTests.swift @@ -1122,17 +1122,18 @@ final class PaywallElementTests: XCTestCase { XCTAssertTrue(isAnnual, "Can switch back to annual") } - /// Three subscribe tiers exist - func testPaywall_threeTiers() { - let tiers = ["pro", "coach", "family"] - XCTAssertEqual(tiers.count, 3, "Should have Pro, Coach, Family tiers") - } - - /// Family tier is always annual - func testPaywall_familyAlwaysAnnual() { - // Family subscribe button always passes annual=true - let familyAnnual = true - XCTAssertTrue(familyAnnual, "Family tier is always annual") + /// Coach is the only public paid plan. + func testPaywall_singlePaidPlan() { + let merchandisedTier = SubscriptionTier.merchandisedTier + XCTAssertEqual(merchandisedTier, .coach) + } + + /// Annual Coach pricing lands at about 50% off monthly. + func testPaywall_annualCoachDiscountIsAboutHalfOff() { + let monthlyAnnualized = SubscriptionTier.coach.monthlyPrice * 12 + let savingsRatio = 1.0 - (SubscriptionTier.coach.annualPrice / monthlyAnnualized) + XCTAssertGreaterThan(savingsRatio, 0.49) + XCTAssertLessThan(savingsRatio, 0.51) } /// Restore purchases button exists diff --git a/apps/HeartCoach/Tests/UserModelsTests.swift b/apps/HeartCoach/Tests/UserModelsTests.swift index 1c983405..845182c8 100644 --- a/apps/HeartCoach/Tests/UserModelsTests.swift +++ b/apps/HeartCoach/Tests/UserModelsTests.swift @@ -137,8 +137,8 @@ final class UserModelsTests: XCTestCase { } func testSubscriptionTier_coachTier_pricing() { - XCTAssertEqual(SubscriptionTier.coach.monthlyPrice, 6.99) - XCTAssertEqual(SubscriptionTier.coach.annualPrice, 59.99) + XCTAssertEqual(SubscriptionTier.coach.monthlyPrice, 2.99) + XCTAssertEqual(SubscriptionTier.coach.annualPrice, 17.99) } func testSubscriptionTier_familyTier_annualOnlyPricing() { @@ -166,14 +166,30 @@ final class UserModelsTests: XCTestCase { "Pro should have more features than Free") } - func testSubscriptionTier_allTiers_currentlyAllowFullAccess() { - // NOTE: Currently all features are free. If this changes, tests should be updated. - for tier in SubscriptionTier.allCases { - XCTAssertTrue(tier.canAccessFullMetrics) - XCTAssertTrue(tier.canAccessNudges) - XCTAssertTrue(tier.canAccessReports) - XCTAssertTrue(tier.canAccessCorrelations) - } + func testSubscriptionTier_merchandisedTier_isCoach() { + XCTAssertEqual(SubscriptionTier.merchandisedTier, .coach) + } + + func testSubscriptionTier_featureGates_matchPlanShape() { + XCTAssertFalse(SubscriptionTier.free.canAccessFullMetrics) + XCTAssertFalse(SubscriptionTier.free.canAccessNudges) + XCTAssertFalse(SubscriptionTier.free.canAccessReports) + XCTAssertFalse(SubscriptionTier.free.canAccessCorrelations) + + XCTAssertTrue(SubscriptionTier.pro.canAccessFullMetrics) + XCTAssertTrue(SubscriptionTier.pro.canAccessNudges) + XCTAssertFalse(SubscriptionTier.pro.canAccessReports) + XCTAssertTrue(SubscriptionTier.pro.canAccessCorrelations) + + XCTAssertTrue(SubscriptionTier.coach.canAccessFullMetrics) + XCTAssertTrue(SubscriptionTier.coach.canAccessNudges) + XCTAssertTrue(SubscriptionTier.coach.canAccessReports) + XCTAssertTrue(SubscriptionTier.coach.canAccessCorrelations) + + XCTAssertTrue(SubscriptionTier.family.canAccessFullMetrics) + XCTAssertTrue(SubscriptionTier.family.canAccessNudges) + XCTAssertTrue(SubscriptionTier.family.canAccessReports) + XCTAssertTrue(SubscriptionTier.family.canAccessCorrelations) } // MARK: - AlertMeta diff --git a/apps/HeartCoach/UITests/ClickableValidationTests.swift b/apps/HeartCoach/UITests/ClickableValidationTests.swift index a2457cc0..32503ac0 100644 --- a/apps/HeartCoach/UITests/ClickableValidationTests.swift +++ b/apps/HeartCoach/UITests/ClickableValidationTests.swift @@ -76,12 +76,15 @@ final class ClickableValidationTests: XCTestCase { func testTabSettings() { screenshot("tab_settings_before") app.tabBars.buttons["Settings"].tap() - // Verify Settings rendered by looking for actual content, not just a container type. - // Form/List renders as UICollectionView on iOS 17+, not UIScrollView or UITableView. - let bugReportButton = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Report a Bug'")).firstMatch - let hasSettingsContent = bugReportButton.waitForExistence(timeout: 5) || - app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'Notifications'")).firstMatch.waitForExistence(timeout: 3) - XCTAssertTrue(hasSettingsContent, "Settings tab should show settings content (bug report or notifications)") + let settingsScreen = app.collectionViews["settings_screen"] + let settingsTitle = app.navigationBars["Settings"] + let profileHeader = app.staticTexts["Profile"] + let subscriptionHeader = app.staticTexts["Subscription"] + let hasSettingsContent = settingsScreen.waitForExistence(timeout: 5) || + settingsTitle.waitForExistence(timeout: 5) || + profileHeader.waitForExistence(timeout: 5) || + subscriptionHeader.waitForExistence(timeout: 5) + XCTAssertTrue(hasSettingsContent, "Settings tab should show the settings screen or its section headers") screenshot("tab_settings_after") } diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift index dbd2206a..a0f6314a 100644 --- a/apps/HeartCoach/iOS/ThumpiOSApp.swift +++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift @@ -142,11 +142,6 @@ struct ThumpiOSApp: App { LegalGateView { legalAccepted = true } } 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 { @@ -219,11 +214,11 @@ struct ThumpiOSApp: App { // Sync subscription tier to local store await MainActor.run { if localStore.profile.isInLaunchFreeYear { - // Launch promotion: grant full Coach access for the first year + // Preserve grandfathered launch access for existing users only. subscriptionService.currentTier = .coach localStore.tier = .coach localStore.saveTier() - AppLogger.info("Launch free year active — Coach tier granted (\(localStore.profile.launchFreeDaysRemaining) days remaining)") + AppLogger.info("Grandfathered launch access 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 diff --git a/apps/HeartCoach/iOS/Views/AppleSignInView.swift b/apps/HeartCoach/iOS/Views/AppleSignInView.swift index 4be621a1..02471951 100644 --- a/apps/HeartCoach/iOS/Views/AppleSignInView.swift +++ b/apps/HeartCoach/iOS/Views/AppleSignInView.swift @@ -57,7 +57,7 @@ struct AppleSignInView: View { // Privacy reassurance Label( - "Your health data stays on your device", + "Health insights run on your device; sharing is opt-in", systemImage: "lock.shield.fill" ) .font(.footnote) @@ -76,13 +76,29 @@ struct AppleSignInView: View { .padding(.horizontal, 32) .padding(.bottom, 16) - // Skip option for development/testing + // Personal Team provisioning cannot use Sign in with Apple on-device. + // Keep a clearly labeled debug path so phone builds still work. #if DEBUG - Button("Skip Sign-In (Debug)") { - onSignedIn() + VStack(spacing: 8) { + Text("Developer build: use Dev Mode if this phone build is signed with a Personal Team account.") + .font(.caption2) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button("Continue in Dev Mode") { + InteractionLog.log( + .buttonTap, + element: "continue_in_dev_mode", + page: "SignIn", + details: "debug_bypass" + ) + AppLogger.info("Using debug sign-in bypass for development build") + onSignedIn() + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } - .font(.caption) - .foregroundStyle(.tertiary) .padding(.bottom, 8) #endif @@ -151,7 +167,7 @@ struct AppleSignInView: View { } AppLogger.error("Sign in with Apple failed: \(error.localizedDescription)") - errorMessage = "Sign-in failed: \(error.localizedDescription)" + errorMessage = friendlyErrorMessage(for: nsError) showError = true InteractionLog.log( @@ -162,6 +178,37 @@ struct AppleSignInView: View { ) } } + + private func friendlyErrorMessage(for error: NSError) -> String { + if error.domain == ASAuthorizationError.errorDomain { + switch ASAuthorizationError.Code(rawValue: error.code) { + case .failed: + return "Sign in with Apple could not start correctly. Please check that the app's Sign in with Apple capability is enabled for this build and try again." + case .invalidResponse: + return "Apple returned an invalid sign-in response. Please try again." + case .notHandled: + return "The sign-in request was not completed. Please try again." + case .notInteractive: + return "Sign in with Apple is not available interactively in this context. Try again from the app on your phone." + case .matchedExcludedCredential: + return "Apple matched a credential that this build cannot use. Please try another account or use Dev Mode for local testing." + case .credentialImport, .credentialExport: + return "This Apple credential could not be used by the current build. Please try again." + case .preferSignInWithApple: + return "This account prefers Sign in with Apple. Please use the Apple sign-in button to continue." + case .deviceNotConfiguredForPasskeyCreation: + return "This device is not configured for that Apple sign-in flow yet. Please check iCloud Keychain settings and try again." + case .unknown, .none: + break + case .canceled: + return "Sign-in was canceled." + @unknown default: + break + } + } + + return "Sign-in failed: \(error.localizedDescription)" + } } // MARK: - Preview diff --git a/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift b/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift index d685f830..8cd5aa46 100644 --- a/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift +++ b/apps/HeartCoach/iOS/Views/LaunchCongratsView.swift @@ -1,16 +1,16 @@ // 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. +// Grandfathered launch-access screen shown for users who were enrolled +// in the original complimentary first-year offer. // 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. +/// Full-screen launch-access view shown for grandfathered users, +/// informing them that their complimentary Coach access is still active. struct LaunchCongratsView: View { /// Called when the user taps "Get Started" to dismiss and continue. @@ -59,7 +59,7 @@ struct LaunchCongratsView: View { .fontWeight(.bold) .multilineTextAlignment(.center) - Text("1 Year Free Access") + Text("Grandfathered Launch Access") .font(.title2) .fontWeight(.semibold) .foregroundStyle( @@ -70,7 +70,7 @@ struct LaunchCongratsView: View { ) ) - Text("You have full access to every Thump feature for one year — completely free. No subscription required.") + Text("You joined during the launch period, so Coach stays unlocked for your complimentary first year.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/apps/HeartCoach/iOS/Views/LegalView.swift b/apps/HeartCoach/iOS/Views/LegalView.swift index cf761375..987b74e1 100644 --- a/apps/HeartCoach/iOS/Views/LegalView.swift +++ b/apps/HeartCoach/iOS/Views/LegalView.swift @@ -254,7 +254,7 @@ struct TermsOfServiceContent: View { "\"User,\" \"you,\" or \"your\" means the individual who downloads, installs, accesses, or uses the Application.", "\"Health Data\" means any biometric, physiological, fitness, or wellness information read from Apple HealthKit or generated by the Application, including but not limited to heart rate, heart rate variability, recovery metrics, VO2 max estimates, step counts, sleep data, and workout metrics.", "\"Output\" means any score, insight, trend, nudge, suggestion, indicator, visualization, alert, or other information generated, computed, or displayed by the Application.", - "\"Subscription\" means a paid recurring entitlement to premium features within the Application, available in the tiers described in Section 6.", + "\"Subscription\" means a paid recurring entitlement to premium features within the Application, currently offered through the Coach plan described in Section 6.", "\"Account Data\" means the information collected through Sign in with Apple, including your name (if provided), email address (if provided), and a pseudonymous user identifier used to associate your preferences and optional server-side data with your account." ]) } @@ -306,7 +306,7 @@ struct TermsOfServiceContent: View { legalSection(number: "6", title: "Subscriptions, Billing, and In-App Purchases") { paragraphs([ - "6.1 Subscription Tiers. The Application offers optional paid Subscription tiers (currently designated Pro, Coach, and Family) that provide access to premium features beyond those available in the free tier. Feature availability is subject to change at the Company's discretion.", + "6.1 Subscription Offering. The Application currently offers an optional paid Coach Subscription that provides access to premium features beyond those available in the free tier. The Company may add, remove, rename, or discontinue Subscription offerings in the future at its discretion.", "6.2 Apple App Store Billing. All Subscriptions and in-app purchases are processed exclusively through Apple's App Store payment infrastructure. The Company does not collect, process, store, or have access to your payment card number, billing address, or other payment instrument details. All billing disputes must be directed to Apple Inc.", "6.3 Automatic Renewal. Subscriptions automatically renew at the end of each billing period (monthly or annual, as selected by you) at the then-current subscription price unless you cancel at least twenty-four (24) hours prior to the end of the then-current billing period. Renewal charges will be applied to the payment method associated with your Apple ID.", "6.4 Cancellation. You may cancel your Subscription at any time through your Apple ID account settings. Cancellation takes effect at the end of the then-current paid billing period. Access to premium features will continue until the end of that period. The Company does not provide partial refunds for unused portions of a billing period.", diff --git a/apps/HeartCoach/iOS/Views/PaywallView.swift b/apps/HeartCoach/iOS/Views/PaywallView.swift index d6769786..fbad042b 100644 --- a/apps/HeartCoach/iOS/Views/PaywallView.swift +++ b/apps/HeartCoach/iOS/Views/PaywallView.swift @@ -2,8 +2,8 @@ // Thump iOS // // Subscription paywall presented modally. Features a gradient hero section, -// pricing cards for all three paid tiers, a feature comparison table, -// and legal links. Integrates with SubscriptionService for purchase and restore flows. +// a single Coach plan card, a free-vs-paid comparison, and legal links. +// Integrates with SubscriptionService for purchase and restore flows. // // Platforms: iOS 17+ @@ -11,9 +11,9 @@ import SwiftUI // MARK: - PaywallView -/// Full-screen subscription paywall with all tier pricing and purchase actions. +/// Full-screen subscription paywall with the public Coach plan and purchase actions. /// -/// Presents pricing for Pro, Coach, and Family tiers with a monthly/annual toggle. +/// Presents a single paid plan with monthly and annual billing options. /// Restore purchases and legal links are provided at the bottom. struct PaywallView: View { @@ -92,15 +92,14 @@ struct PaywallView: View { .foregroundStyle(.white) .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - Text("Unlock Full Insights") + Text("Unlock Coach") .font(.title) .fontWeight(.bold) .foregroundStyle(.white) Text( - "Your heart training buddy. Deep analytics, weekly reports, " - + "and wellness insights to help you " - + "understand your heart health trends." + "Go beyond the daily snapshot with the full dashboard, " + + "weekly reviews, deeper trends, and shareable wellness summaries." ) .font(.subheadline) .foregroundStyle(.white.opacity(0.9)) @@ -123,7 +122,7 @@ struct PaywallView: View { .padding(.horizontal, 24) if isAnnual { - Text("Save up to 37% with annual billing") + Text("Annual saves about 50% compared with monthly") .font(.caption) .foregroundStyle(.green) .fontWeight(.medium) @@ -136,37 +135,19 @@ struct PaywallView: View { // MARK: - Pricing Cards private var pricingCards: some View { - VStack(spacing: 16) { - pricingCard( - tier: .pro, - badge: nil, - accentColor: .pink - ) - - pricingCard( - tier: .coach, - badge: "Most Popular", - accentColor: .purple - ) - - familyCard - } + coachCard .padding(.horizontal, 20) .padding(.top, 16) } - private func pricingCard( - tier: SubscriptionTier, - badge: String?, - accentColor: Color - ) -> some View { + private var coachCard: some View { + let tier = SubscriptionTier.merchandisedTier + let accentColor = Color.purple let price = isAnnual ? tier.annualPrice : tier.monthlyPrice let period = isAnnual ? "/year" : "/mo" let monthlyEquivalent = isAnnual ? tier.annualPrice / 12 : tier.monthlyPrice - let isHighlighted = badge != nil return VStack(spacing: 14) { - // Tier header HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -175,22 +156,22 @@ struct PaywallView: View { .fontWeight(.bold) .foregroundStyle(.primary) - if let badge { - Text(badge) - .font(.caption2) - .fontWeight(.bold) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(accentColor, in: Capsule()) - } + Text(isAnnual ? "Best Value" : "Monthly") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(accentColor, in: Capsule()) } - if isAnnual { - Text("$\(String(format: "%.2f", monthlyEquivalent))/mo equivalent") - .font(.caption) - .foregroundStyle(.secondary) - } + Text( + isAnnual + ? "$\(String(format: "%.2f", monthlyEquivalent))/mo billed yearly" + : "Full dashboard, weekly reviews, and PDF summaries" + ) + .font(.caption) + .foregroundStyle(.secondary) } Spacer() @@ -209,7 +190,6 @@ struct PaywallView: View { Divider() - // Feature list VStack(alignment: .leading, spacing: 8) { ForEach(tier.features, id: \.self) { feature in HStack(alignment: .top, spacing: 8) { @@ -226,7 +206,6 @@ struct PaywallView: View { } } - // Subscribe button Button { InteractionLog.log(.buttonTap, element: "subscribe_\(tier.rawValue)", page: "Paywall", details: "annual=\(isAnnual)") subscribe(to: tier) @@ -236,7 +215,7 @@ struct PaywallView: View { ProgressView() .tint(.white) } - Text("Subscribe to \(tier.displayName)") + Text(isAnnual ? "Start Coach Annual" : "Start Coach Monthly") .fontWeight(.semibold) } .font(.subheadline) @@ -244,7 +223,7 @@ struct PaywallView: View { .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( - isHighlighted ? AnyShapeStyle(accentColor) : AnyShapeStyle(accentColor.opacity(0.85)), + AnyShapeStyle(accentColor), in: RoundedRectangle(cornerRadius: 12) ) } @@ -258,122 +237,17 @@ struct PaywallView: View { .overlay( RoundedRectangle(cornerRadius: 18) .strokeBorder( - isHighlighted ? accentColor.opacity(0.4) : Color(.systemGray4), - lineWidth: isHighlighted ? 2 : 1 + accentColor.opacity(0.4), + lineWidth: 2 ) ) } - /// Family plan card — annual-only with a special note about member count. - private var familyCard: some View { - let accentColor = Color.orange - - return VStack(spacing: 14) { - // Tier header - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(SubscriptionTier.family.displayName) - .font(.title3) - .fontWeight(.bold) - .foregroundStyle(.primary) - - Text("Up to 5 Members") - .font(.caption2) - .fontWeight(.bold) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(accentColor, in: Capsule()) - } - - Text("Annual plan · one shared subscription") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("$\(String(format: "%.2f", SubscriptionTier.family.annualPrice))") - .font(.title2) - .fontWeight(.bold) - .foregroundStyle(accentColor) - - Text("/year") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if !isAnnual { - HStack(spacing: 6) { - Image(systemName: "info.circle") - .font(.caption) - .foregroundStyle(accentColor) - Text("Family plan is available on annual billing only.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 4) - } - - Divider() - - // Feature list - VStack(alignment: .leading, spacing: 8) { - ForEach(SubscriptionTier.family.features, id: \.self) { feature in - HStack(alignment: .top, spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(accentColor) - .padding(.top, 2) - - Text(feature) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - // Subscribe button — always uses annual price - Button { - InteractionLog.log(.buttonTap, element: "subscribe_family", page: "Paywall", details: "annual=true") - subscribe(to: .family) - } label: { - HStack { - if isPurchasing { - ProgressView() - .tint(.white) - } - Text("Subscribe to Family") - .fontWeight(.semibold) - } - .font(.subheadline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(accentColor, in: RoundedRectangle(cornerRadius: 12)) - } - .disabled(isPurchasing) - } - .padding(18) - .background( - RoundedRectangle(cornerRadius: 18) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18) - .strokeBorder(accentColor.opacity(0.4), lineWidth: 2) - ) - } - // MARK: - Feature Comparison private var featureComparison: some View { VStack(alignment: .leading, spacing: 16) { - Text("Compare Plans") + Text("Free vs Coach") .font(.headline) .foregroundStyle(.primary) .padding(.horizontal, 4) @@ -381,21 +255,19 @@ struct PaywallView: View { VStack(spacing: 0) { comparisonHeader Divider() - comparisonRow(feature: "Wellness Snapshot", free: true, pro: true, coach: true, family: true) - Divider() - comparisonRow(feature: "Full Dashboard", free: false, pro: true, coach: true, family: true) + comparisonRow(feature: "Daily wellness snapshot", free: true, coach: true) Divider() - comparisonRow(feature: "Daily Suggestions", free: false, pro: true, coach: true, family: true) + comparisonRow(feature: "Basic trend view", free: true, coach: true) Divider() - comparisonRow(feature: "Connections", free: false, pro: true, coach: true, family: true) + comparisonRow(feature: "Full metrics dashboard", free: false, coach: true) Divider() - comparisonRow(feature: "Weekly Reviews", free: false, pro: false, coach: true, family: true) + comparisonRow(feature: "Personalized nudges", free: false, coach: true) Divider() - comparisonRow(feature: "Wellness Summaries", free: false, pro: false, coach: true, family: true) + comparisonRow(feature: "Stress and anomaly context", free: false, coach: true) Divider() - comparisonRow(feature: "Caregiver Mode", free: false, pro: false, coach: false, family: true) + comparisonRow(feature: "Weekly reviews", free: false, coach: true) Divider() - comparisonRow(feature: "Shared Goals", free: false, pro: false, coach: false, family: true) + comparisonRow(feature: "PDF wellness summaries", free: false, coach: true) } .background( RoundedRectangle(cornerRadius: 14) @@ -419,25 +291,13 @@ struct PaywallView: View { .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) - .frame(width: 40) - - Text("Pro") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.pink) - .frame(width: 40) + .frame(width: 52) Text("Coach") .font(.caption) .fontWeight(.semibold) .foregroundStyle(.purple) - .frame(width: 40) - - Text("Family") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.orange) - .frame(width: 44) + .frame(width: 56) } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -447,9 +307,7 @@ struct PaywallView: View { private func comparisonRow( feature: String, free: Bool, - pro: Bool, - coach: Bool, - family: Bool + coach: Bool ) -> some View { HStack { Text(feature) @@ -457,10 +315,8 @@ struct PaywallView: View { .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) - checkOrCross(free).frame(width: 40) - checkOrCross(pro, color: .pink).frame(width: 40) - checkOrCross(coach, color: .purple).frame(width: 40) - checkOrCross(family, color: .orange).frame(width: 44) + checkOrCross(free).frame(width: 52) + checkOrCross(coach, color: .purple).frame(width: 56) } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -527,9 +383,7 @@ struct PaywallView: View { isPurchasing = true Task { do { - // Family plan is always annual; all others respect the toggle. - let annual = tier == .family ? true : isAnnual - try await subscriptionService.purchase(tier: tier, isAnnual: annual) + try await subscriptionService.purchase(tier: tier, isAnnual: isAnnual) await MainActor.run { isPurchasing = false dismiss() diff --git a/apps/HeartCoach/iOS/Views/SettingsView.swift b/apps/HeartCoach/iOS/Views/SettingsView.swift index f2a9d3f0..4f80d48b 100644 --- a/apps/HeartCoach/iOS/Views/SettingsView.swift +++ b/apps/HeartCoach/iOS/Views/SettingsView.swift @@ -223,11 +223,10 @@ struct SettingsView: View { private var subscriptionSection: some View { Section { if localStore.profile.isInLaunchFreeYear { - // Launch free year — show status instead of paywall HStack { Label("Current Plan", systemImage: "gift.fill") Spacer() - Text("Coach (Free)") + Text("Coach (Launch Access)") .font(.subheadline) .fontWeight(.medium) .foregroundStyle(.green) @@ -237,36 +236,60 @@ struct SettingsView: View { } HStack { - Label("Free Access", systemImage: "clock.fill") + Label("Launch Access", systemImage: "clock.fill") Spacer() Text("\(localStore.profile.launchFreeDaysRemaining) days remaining") .font(.subheadline) .foregroundStyle(.secondary) } - Text("All features are unlocked for your first year. No payment required.") + Text("You were part of the launch group, so Coach stays unlocked until your complimentary year ends.") .font(.caption) .foregroundStyle(.secondary) } else { - // Phase 2: Paywall paused — show beta messaging HStack { - Label("Current Plan", systemImage: "gift.fill") + Label("Current Plan", systemImage: "creditcard.fill") Spacer() - Text("All Features (Beta)") + Text(currentTierDisplayName) .font(.subheadline) .fontWeight(.medium) - .foregroundStyle(.green) + .foregroundStyle(localStore.tier == .free ? Color.secondary : Color.green) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(Color.green.opacity(0.12), in: Capsule()) + .background( + (localStore.tier == .free ? Color.gray : Color.green).opacity(0.12), + in: Capsule() + ) } - Text("All features are currently free during the beta period. Subscription plans will be available in a future update.") - .font(.caption) - .foregroundStyle(.secondary) + if localStore.tier == .free { + Text("Coach unlocks the full dashboard, weekly reviews, anomaly context, and PDF wellness summaries.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + InteractionLog.log(.buttonTap, element: "open_paywall", page: "Settings") + showPaywall = true + } label: { + HStack { + Text("View Coach Plan") + Spacer() + Text("$2.99/mo or $17.99/year") + .foregroundStyle(.secondary) + } + } + } else { + Text("Billing is managed by Apple. Annual Coach is priced at about 50% off the monthly rate.") + .font(.caption) + .foregroundStyle(.secondary) + } } } header: { Text("Subscription") + } footer: { + if !localStore.profile.isInLaunchFreeYear && localStore.tier == .free { + Text("Pricing shown in the app should match the App Store listing. If StoreKit products are configured differently, the App Store price wins.") + } } } diff --git a/apps/HeartCoach/iOS/iOS.entitlements b/apps/HeartCoach/iOS/iOS.entitlements index 0ec753b3..43436bef 100644 --- a/apps/HeartCoach/iOS/iOS.entitlements +++ b/apps/HeartCoach/iOS/iOS.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default + com.apple.developer.healthkit com.apple.security.application-groups diff --git a/apps/HeartCoach/iOS/iOSDebug.entitlements b/apps/HeartCoach/iOS/iOSDebug.entitlements new file mode 100644 index 00000000..0ec753b3 --- /dev/null +++ b/apps/HeartCoach/iOS/iOSDebug.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.healthkit + + com.apple.security.application-groups + + group.com.health.thump.shared + + + diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml index 81a17b58..5079bb17 100644 --- a/apps/HeartCoach/project.yml +++ b/apps/HeartCoach/project.yml @@ -59,11 +59,15 @@ targets: settings: base: INFOPLIST_FILE: iOS/Info.plist - CODE_SIGN_ENTITLEMENTS: iOS/iOS.entitlements PRODUCT_BUNDLE_IDENTIFIER: com.health.thump.ios - DEVELOPMENT_TEAM: RSF2UZJ4Y3 + DEVELOPMENT_TEAM: Q4TKNL6HW2 TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: false + configs: + Debug: + CODE_SIGN_ENTITLEMENTS: iOS/iOSDebug.entitlements + Release: + CODE_SIGN_ENTITLEMENTS: iOS/iOS.entitlements dependencies: - sdk: HealthKit.framework - sdk: WatchConnectivity.framework @@ -94,7 +98,7 @@ targets: INFOPLIST_FILE: Watch/Info.plist CODE_SIGN_ENTITLEMENTS: Watch/Watch.entitlements PRODUCT_BUNDLE_IDENTIFIER: com.health.thump.ios.watchkitapp - DEVELOPMENT_TEAM: RSF2UZJ4Y3 + DEVELOPMENT_TEAM: Q4TKNL6HW2 TARGETED_DEVICE_FAMILY: "4" WATCHOS_DEPLOYMENT_TARGET: "11.0" dependencies: @@ -125,7 +129,7 @@ targets: base: GENERATE_INFOPLIST_FILE: "YES" PRODUCT_BUNDLE_IDENTIFIER: com.health.thump.tests - DEVELOPMENT_TEAM: RSF2UZJ4Y3 + DEVELOPMENT_TEAM: Q4TKNL6HW2 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Thump.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Thump" BUNDLE_LOADER: "$(TEST_HOST)" @@ -143,7 +147,7 @@ targets: base: GENERATE_INFOPLIST_FILE: "YES" PRODUCT_BUNDLE_IDENTIFIER: com.health.thump.uitests - DEVELOPMENT_TEAM: RSF2UZJ4Y3 + DEVELOPMENT_TEAM: Q4TKNL6HW2 TEST_TARGET_NAME: Thump ############################################################ diff --git a/apps/HeartCoach/web/index.html b/apps/HeartCoach/web/index.html index 4800a08d..b5fa14d7 100644 --- a/apps/HeartCoach/web/index.html +++ b/apps/HeartCoach/web/index.html @@ -1997,7 +1997,7 @@

Invest in your
heart Monthly
Annual - Save up to 40% + Save about 50%
@@ -2030,13 +2030,13 @@

Invest in your
heart

- + - - -
-
Trainer
-
Weekly reports, AI reviews, and shareable PDFs
-
- $6.99 - /month -
-
    -
  • - - Everything in Pro -
  • -
  • - - AI weekly review -
  • -
  • - - Multi-week trend analysis -
  • -
  • - - Doctor-shareable PDF reports -
  • -
  • - - Priority anomaly alerting -
  • -
- -
- - -
-
Family
-
Trainer plan for up to 5 family members
-
- $79.99 - /year - Annual Only -
-
    -
  • - - Everything in Trainer -
  • -
  • - - Up to 5 members -
  • -
  • - - Shared goals & accountability -
  • -
  • - - Caregiver mode -
  • -
- +
@@ -2166,7 +2103,7 @@

People who listen
to their -

"My doctor was impressed when I showed up with the weekly PDF report. It gave us a real conversation about my recovery trends after surgery. Worth every penny of the Trainer plan."

+

"My doctor was impressed when I showed up with the weekly PDF report. It gave us a real conversation about my recovery trends after surgery. Worth every penny of the Coach plan."

DP
@@ -2184,7 +2121,7 @@

People who listen
to their

-

"Family plan is brilliant. I set it up for my parents and can see their trends through caregiver mode. When my dad's resting HR spiked, the alert helped us catch a medication issue early."

+

"Coach helped me stop guessing. The weekly review made it obvious when my recovery was slipping, and that changed how I trained the next few days."

SL