From d9f3a96683f6c0337ad88a12e81e1370694e5dcd Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sun, 15 Mar 2026 10:38:22 -0700 Subject: [PATCH] feat: readiness breakdown sheet, metric explainers, and layout fixes - Recovery context banner now navigates to Stress tab on tap - Readiness badge opens pillar breakdown sheet instead of Insights - Add metric explainer text in Trends chart card (RHR, HRV, etc.) - Switch metric picker to LazyVGrid for even spacing across 3 columns --- .../iOS/Views/DashboardView+ThumpCheck.swift | 114 +++++++++++++++++- apps/HeartCoach/iOS/Views/DashboardView.swift | 3 + apps/HeartCoach/iOS/Views/TrendsView.swift | 43 +++++-- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift index 56854991..e96e16da 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift @@ -25,7 +25,7 @@ extension DashboardView { // Badge is tappable — navigates to buddy recommendations Button { InteractionLog.log(.buttonTap, element: "readiness_badge", page: "Dashboard") - withAnimation { selectedTab = 1 } + showReadinessDetail = true } label: { HStack(spacing: 4) { Text(thumpCheckBadge(result)) @@ -42,8 +42,8 @@ extension DashboardView { ) } .buttonStyle(.plain) - .accessibilityLabel("View buddy recommendations") - .accessibilityHint("Opens Insights tab") + .accessibilityLabel("View readiness breakdown") + .accessibilityHint("Shows what's driving your score") } // Main recommendation — context-aware sentence @@ -102,6 +102,9 @@ extension DashboardView { "Thump Check: \(thumpCheckRecommendation(result))" ) .accessibilityIdentifier("dashboard_readiness_card") + .sheet(isPresented: $showReadinessDetail) { + readinessDetailSheet(result) + } } else if let assessment = viewModel.assessment { StatusCardView( status: assessment.status, @@ -334,6 +337,10 @@ extension DashboardView { ) .accessibilityElement(children: .combine) .accessibilityLabel("Recovery note: \(ctx.reason). Tonight: \(ctx.tonightAction)") + .onTapGesture { + InteractionLog.log(.cardTap, element: "recovery_context_banner", page: "Dashboard") + withAnimation { selectedTab = 2 } + } } /// Shows week-over-week RHR change and recovery trend as a compact banner. @@ -418,6 +425,107 @@ extension DashboardView { } } + // MARK: - Readiness Detail Sheet + + func readinessDetailSheet(_ result: ReadinessResult) -> some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Score circle + level + VStack(spacing: 8) { + ZStack { + Circle() + .stroke(readinessColor(for: result.level).opacity(0.2), lineWidth: 10) + .frame(width: 100, height: 100) + Circle() + .trim(from: 0, to: Double(result.score) / 100.0) + .stroke(readinessColor(for: result.level), style: StrokeStyle(lineWidth: 10, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 100, height: 100) + Text("\(result.score)") + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + } + + Text(thumpCheckBadge(result)) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(readinessColor(for: result.level)) + + Text(result.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + .padding(.top, 8) + + // Pillar breakdown + VStack(spacing: 12) { + Text("What's Driving Your Score") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(result.pillars, id: \.type) { pillar in + HStack(spacing: 12) { + Image(systemName: pillar.type.icon) + .font(.title3) + .foregroundStyle(pillarColor(score: pillar.score)) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pillar.type.displayName) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text("\(Int(pillar.score))") + .font(.subheadline) + .fontWeight(.bold) + .fontDesign(.rounded) + .foregroundStyle(pillarColor(score: pillar.score)) + } + + // Score bar + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color(.systemGray5)) + .frame(height: 6) + RoundedRectangle(cornerRadius: 3) + .fill(pillarColor(score: pillar.score)) + .frame(width: geo.size.width * CGFloat(pillar.score / 100.0), height: 6) + } + } + .frame(height: 6) + + Text(pillar.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(pillarColor(score: pillar.score).opacity(0.06)) + ) + } + } + .padding(.horizontal, 16) + } + .padding(.bottom, 32) + } + .navigationTitle("Readiness Breakdown") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { showReadinessDetail = false } + } + } + } + .presentationDetents([.medium, .large]) + } + func readinessColor(for level: ReadinessLevel) -> Color { switch level { case .primed: return Color(hex: 0x22C55E) diff --git a/apps/HeartCoach/iOS/Views/DashboardView.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift index 8b60dcbf..411aee72 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -36,6 +36,9 @@ struct DashboardView: View { /// Controls the Bio Age detail sheet presentation. @State private var showBioAgeDetail = false + /// Controls the Readiness detail sheet presentation. + @State var showReadinessDetail = false + // MARK: - Grid Layout private let metricColumns = [ diff --git a/apps/HeartCoach/iOS/Views/TrendsView.swift b/apps/HeartCoach/iOS/Views/TrendsView.swift index 107babd4..8085c6c7 100644 --- a/apps/HeartCoach/iOS/Views/TrendsView.swift +++ b/apps/HeartCoach/iOS/Views/TrendsView.swift @@ -114,16 +114,16 @@ struct TrendsView: View { // MARK: - Metric Picker private var metricPicker: some View { - VStack(spacing: 8) { - HStack(spacing: 8) { - metricChip("RHR", icon: "heart.fill", metric: .restingHR) - metricChip("HRV", icon: "waveform.path.ecg", metric: .hrv) - metricChip("Recovery", icon: "arrow.uturn.up", metric: .recovery) - } - HStack(spacing: 8) { - metricChip("Cardio Fitness", icon: "lungs.fill", metric: .vo2Max) - metricChip("Active", icon: "figure.run", metric: .activeMinutes) - } + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ], spacing: 8) { + metricChip("RHR", icon: "heart.fill", metric: .restingHR) + metricChip("HRV", icon: "waveform.path.ecg", metric: .hrv) + metricChip("Recovery", icon: "arrow.uturn.up", metric: .recovery) + metricChip("Cardio", icon: "lungs.fill", metric: .vo2Max) + metricChip("Active", icon: "figure.run", metric: .activeMinutes) } .accessibilityIdentifier("metric_selector") } @@ -143,9 +143,10 @@ struct TrendsView: View { .font(.system(size: 11, weight: .semibold)) Text(label) .font(.system(size: 13, weight: .semibold, design: .rounded)) + .lineLimit(1) } .foregroundStyle(isSelected ? .white : .primary) - .padding(.horizontal, 14) + .frame(maxWidth: .infinity) .padding(.vertical, 9) .background(chipColor, in: Capsule()) .overlay( @@ -216,6 +217,11 @@ struct TrendsView: View { } } + Text(metricExplainer) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + TrendChartView( dataPoints: points, metricLabel: metricUnit, @@ -996,6 +1002,21 @@ struct TrendsView: View { } } + private var metricExplainer: String { + switch viewModel.selectedMetric { + case .restingHR: + return "Your heart rate at complete rest — lower generally means better cardiovascular fitness. Athletes often sit in the 40–60 bpm range." + case .hrv: + return "The variation in time between heartbeats. Higher HRV signals better stress resilience and recovery capacity." + case .recovery: + return "How quickly your heart rate drops after exercise. A faster drop (higher number) indicates stronger cardiovascular fitness." + case .vo2Max: + return "An estimate of your VO2 max — how efficiently your body uses oxygen. Higher scores mean better endurance." + case .activeMinutes: + return "Total minutes of walking and workout activity. The AHA recommends 150+ minutes of moderate activity per week." + } + } + private var metricIcon: String { switch viewModel.selectedMetric { case .restingHR: return "heart.fill"