From d94d893501c0304e7c5305eb355bb14d6f9e806a Mon Sep 17 00:00:00 2001 From: mission-agi Date: Sun, 15 Mar 2026 02:09:06 -0700 Subject: [PATCH] feat: week-over-week trends, metric impact tags, and UX affordance fixes - Add week-over-week RHR and recovery trend banner in Thump Check card - Show metric impact labels on buddy recommendations (e.g. "Improves VO2 max") - Add CardButtonStyle with press feedback for tappable cards - Make How You Recovered card and trend banner navigate to Trends tab - Replace .buttonStyle(.plain) with CardButtonStyle on metric tiles and buddy cards --- .../iOS/Views/DashboardView+BuddyCards.swift | 41 ++++++++- .../iOS/Views/DashboardView+Recovery.swift | 4 + .../iOS/Views/DashboardView+ThumpCheck.swift | 87 +++++++++++++++++++ apps/HeartCoach/iOS/Views/DashboardView.swift | 12 ++- 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift b/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift index 1ba5eff9..3344e4a2 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift @@ -55,7 +55,7 @@ extension DashboardView { } ) } - .buttonStyle(.plain) + .buttonStyle(CardButtonStyle()) .accessibilityHint("Double tap to view details") } } @@ -183,6 +183,16 @@ extension DashboardView { .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + // Metric impact tag + HStack(spacing: 4) { + Image(systemName: metricImpactIcon(rec.category)) + .font(.system(size: 8)) + Text(metricImpactLabel(rec.category)) + .font(.system(size: 9, weight: .medium)) + } + .foregroundStyle(buddyRecColor(rec)) + .padding(.top, 2) } Spacer() @@ -201,7 +211,7 @@ extension DashboardView { .strokeBorder(buddyRecColor(rec).opacity(0.12), lineWidth: 1) ) } - .buttonStyle(.plain) + .buttonStyle(CardButtonStyle()) .accessibilityLabel("\(rec.title): \(rec.message)") .accessibilityHint("Double tap for details") } @@ -240,4 +250,31 @@ extension DashboardView { case .sunlight: return Color(hex: 0xF59E0B) } } + + /// Maps a recommendation category to the metric it improves. + func metricImpactLabel(_ category: NudgeCategory) -> String { + switch category { + case .walk: return "Improves VO2 max & recovery" + case .rest: return "Lowers resting heart rate" + case .hydrate: return "Supports HRV & recovery" + case .breathe: return "Reduces stress score" + case .moderate: return "Boosts cardio fitness" + case .celebrate: return "Keep it up!" + case .seekGuidance: return "Protect your heart health" + case .sunlight: return "Improves sleep & circadian rhythm" + } + } + + func metricImpactIcon(_ category: NudgeCategory) -> String { + switch category { + case .walk: return "arrow.up.heart.fill" + case .rest: return "heart.fill" + case .hydrate: return "waveform.path.ecg" + case .breathe: return "brain.head.profile" + case .moderate: return "lungs.fill" + case .celebrate: return "star.fill" + case .seekGuidance: return "shield.fill" + case .sunlight: return "moon.zzz.fill" + } + } } diff --git a/apps/HeartCoach/iOS/Views/DashboardView+Recovery.swift b/apps/HeartCoach/iOS/Views/DashboardView+Recovery.swift index f805db3c..c7ff17fb 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+Recovery.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+Recovery.swift @@ -123,6 +123,10 @@ extension DashboardView { .accessibilityElement(children: .combine) .accessibilityLabel("How you recovered: \(recoveryNarrative(wow: wow))") .accessibilityIdentifier("dashboard_recovery_card") + .onTapGesture { + InteractionLog.log(.cardTap, element: "recovery_card", page: "Dashboard") + withAnimation { selectedTab = 3 } + } } } diff --git a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift index a6683148..56854991 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift @@ -75,6 +75,11 @@ extension DashboardView { ) } + // Week-over-week trend indicators + if let trend = viewModel.assessment?.weekOverWeekTrend { + weekOverWeekBanner(trend) + } + // Recovery context banner — shown when readiness is low. if let ctx = viewModel.assessment?.recoveryContext { recoveryContextBanner(ctx) @@ -331,6 +336,88 @@ extension DashboardView { .accessibilityLabel("Recovery note: \(ctx.reason). Tonight: \(ctx.tonightAction)") } + /// Shows week-over-week RHR change and recovery trend as a compact banner. + func weekOverWeekBanner(_ trend: WeekOverWeekTrend) -> some View { + let rhrChange = trend.currentWeekMean - trend.baselineMean + let rhrArrow = rhrChange <= -1 ? "↓" : rhrChange >= 1 ? "↑" : "→" + let rhrColor: Color = rhrChange <= -1 + ? Color(hex: 0x22C55E) + : rhrChange >= 1 ? Color(hex: 0xEF4444) : .secondary + + return VStack(spacing: 6) { + // RHR trend line + HStack(spacing: 6) { + Image(systemName: trend.direction.icon) + .font(.caption2) + .foregroundStyle(rhrColor) + Text("RHR \(Int(trend.baselineMean)) \(rhrArrow) \(Int(trend.currentWeekMean)) bpm") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + Spacer() + Text(trendLabel(trend.direction)) + .font(.system(size: 9)) + .foregroundStyle(rhrColor) + } + + // Recovery trend line (if available) + if let recovery = viewModel.assessment?.recoveryTrend, + recovery.direction != .insufficientData, + let current = recovery.currentWeekMean, + let baseline = recovery.baselineMean { + let recChange = current - baseline + let recArrow = recChange >= 1 ? "↑" : recChange <= -1 ? "↓" : "→" + let recColor: Color = recChange >= 1 + ? Color(hex: 0x22C55E) + : recChange <= -1 ? Color(hex: 0xEF4444) : .secondary + + HStack(spacing: 6) { + Image(systemName: "arrow.uturn.up") + .font(.caption2) + .foregroundStyle(recColor) + Text("Recovery \(Int(baseline)) \(recArrow) \(Int(current)) bpm drop") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + Spacer() + Text(recoveryDirectionLabel(recovery.direction)) + .font(.system(size: 9)) + .foregroundStyle(recColor) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.tertiarySystemGroupedBackground)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("RHR trend: \(Int(trend.baselineMean)) to \(Int(trend.currentWeekMean)) bpm, \(trendLabel(trend.direction))") + .onTapGesture { + InteractionLog.log(.cardTap, element: "wow_trend_banner", page: "Dashboard") + withAnimation { selectedTab = 3 } + } + } + + func trendLabel(_ direction: WeeklyTrendDirection) -> String { + switch direction { + case .significantImprovement: return "Improving fast" + case .improving: return "Trending down" + case .stable: return "Steady" + case .elevated: return "Creeping up" + case .significantElevation: return "Elevated" + } + } + + func recoveryDirectionLabel(_ direction: RecoveryTrendDirection) -> String { + switch direction { + case .improving: return "Getting faster" + case .stable: return "Steady" + case .declining: return "Slowing down" + case .insufficientData: return "Not enough data" + } + } + 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 32452c63..8b60dcbf 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -612,11 +612,21 @@ struct DashboardView: View { isLocked: false ) } - .buttonStyle(.plain) + .buttonStyle(CardButtonStyle()) .accessibilityHint("Double tap to view trends") } } +/// Button style that adds a subtle press effect for card-like buttons. +struct CardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.7 : 1.0) + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + // MARK: - Preview #Preview("Dashboard - Loaded") {