@@ -79,6 +79,7 @@ struct TrendsView: View {
7979 } else {
8080 chartCard ( points: points)
8181 summaryStats ( points: points)
82+ trendInsightCard ( points: points)
8283 }
8384 }
8485 . padding ( 16 )
@@ -136,6 +137,126 @@ struct TrendsView: View {
136137 )
137138 }
138139
140+ // MARK: - Trend Insight Card
141+
142+ private func trendInsightCard( points: [ ( date: Date , value: Double ) ] ) -> some View {
143+ let insight = trendInsight ( for: points)
144+ return VStack ( alignment: . leading, spacing: 12 ) {
145+ HStack ( spacing: 8 ) {
146+ Image ( systemName: insight. icon)
147+ . foregroundStyle ( insight. color)
148+ Text ( " What's Happening " )
149+ . font ( . headline)
150+ . foregroundStyle ( . primary)
151+ }
152+
153+ Text ( insight. headline)
154+ . font ( . subheadline)
155+ . fontWeight ( . semibold)
156+ . foregroundStyle ( insight. color)
157+
158+ Text ( insight. detail)
159+ . font ( . subheadline)
160+ . foregroundStyle ( . secondary)
161+ . fixedSize ( horizontal: false , vertical: true )
162+ }
163+ . padding ( 16 )
164+ . frame ( maxWidth: . infinity, alignment: . leading)
165+ . background (
166+ RoundedRectangle ( cornerRadius: 16 )
167+ . fill ( insight. color. opacity ( 0.08 ) )
168+ )
169+ . overlay (
170+ RoundedRectangle ( cornerRadius: 16 )
171+ . strokeBorder ( insight. color. opacity ( 0.2 ) , lineWidth: 1 )
172+ )
173+ }
174+
175+ private struct TrendInsight {
176+ let headline : String
177+ let detail : String
178+ let icon : String
179+ let color : Color
180+ }
181+
182+ private func trendInsight( for points: [ ( date: Date , value: Double ) ] ) -> TrendInsight {
183+ let values = points. map ( \. value)
184+ guard values. count >= 4 else {
185+ return TrendInsight (
186+ headline: " Not enough data yet " ,
187+ detail: " Check back after a few more days of wear to see your trend analysis. " ,
188+ icon: " clock " ,
189+ color: . secondary
190+ )
191+ }
192+
193+ let midpoint = values. count / 2
194+ let firstAvg = values. prefix ( midpoint) . reduce ( 0 , + ) / Double( midpoint)
195+ let secondAvg = values. suffix ( values. count - midpoint) . reduce ( 0 , + ) / Double( values. count - midpoint)
196+ let percentChange = ( secondAvg - firstAvg) / firstAvg * 100
197+
198+ // For RHR: lower is better. For everything else: higher is better.
199+ let lowerIsBetter = viewModel. selectedMetric == . restingHR
200+ let improving = lowerIsBetter ? percentChange < - 2 : percentChange > 2
201+ let worsening = lowerIsBetter ? percentChange > 2 : percentChange < - 2
202+ let change = abs ( percentChange)
203+
204+ let rangeDescription : String
205+ if change < 2 {
206+ rangeDescription = " less than 2% "
207+ } else if change < 5 {
208+ rangeDescription = " about \( Int ( change) ) % "
209+ } else {
210+ rangeDescription = " \( Int ( change) ) % "
211+ }
212+
213+ let metricName = metricDisplayName. lowercased ( )
214+
215+ // Context note for short windows — 7 days is noisy, so soften "Worth Watching"
216+ let shortWindow = viewModel. timeRange == . week
217+ let windowNote = shortWindow
218+ ? " Short windows can look noisy — switch to 14D or 30D for the bigger picture. "
219+ : " "
220+
221+ if change < 2 {
222+ return TrendInsight (
223+ headline: " Holding Steady " ,
224+ detail: " Your \( metricName) has been consistent — barely any change "
225+ + " between the start and end of this period. "
226+ + " Stability is a good sign of a healthy baseline. " ,
227+ icon: " arrow.right.circle.fill " ,
228+ color: . blue
229+ )
230+ } else if improving {
231+ return TrendInsight (
232+ headline: " Trending Better " ,
233+ detail: " Your \( metricName) has improved by \( rangeDescription) "
234+ + " over this period. Keep up whatever you've been doing "
235+ + " — the trend is moving in the right direction. " ,
236+ icon: " arrow.up.right.circle.fill " ,
237+ color: . green
238+ )
239+ } else if worsening {
240+ return TrendInsight (
241+ headline: " Worth Watching " ,
242+ detail: " Your \( metricName) has shifted by \( rangeDescription) "
243+ + " in a direction worth monitoring. This is often normal "
244+ + " after a harder training day, poor sleep, "
245+ + " or schedule changes. \( windowNote) " ,
246+ icon: " arrow.down.right.circle.fill " ,
247+ color: . orange
248+ )
249+ } else {
250+ return TrendInsight (
251+ headline: " Holding Steady " ,
252+ detail: " Your \( metricName) has been consistent over this "
253+ + " period. Stability is a good sign of a healthy baseline. " ,
254+ icon: " arrow.right.circle.fill " ,
255+ color: . blue
256+ )
257+ }
258+ }
259+
139260 private func statItem( label: String , value: String ) -> some View {
140261 VStack ( spacing: 4 ) {
141262 Text ( value)
0 commit comments