Files
workouts/Workouts/Views/Progress/TrendsSection.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

135 lines
4.2 KiB
Swift

//
// TrendsSection.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import Charts
/// Aggregate trends over time: one chart, a metric picker (workouts, volume, active
/// time, energy), and the standard 7D/30D/90D/1Y/All range picker. Counts render as
/// bars (discrete events); the other metrics as the smooth line + points pattern.
struct TrendsSection: View {
let workouts: [WorkoutFacts]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@State private var range: TrendRange = .month
@State private var metric: Metric = .workouts
enum Metric: String, CaseIterable, Identifiable {
case workouts = "Workouts"
case volume = "Volume"
case time = "Time"
case energy = "Energy"
var id: String { rawValue }
var color: Color {
switch self {
case .workouts: .blue
case .volume: .purple
case .time: .green
case .energy: .orange
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Trends")
.font(.headline)
Picker("Metric", selection: $metric) {
ForEach(Metric.allCases) { metric in
Text(metric.rawValue).tag(metric)
}
}
.pickerStyle(.segmented)
Picker("Range", selection: $range) {
ForEach(TrendRange.allCases) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
chart
}
.progressCard()
}
@ViewBuilder
private var chart: some View {
let points = ProgressPlanner.trendPoints(workouts: workouts, range: range)
if points.isEmpty || points.allSatisfy({ $0.workouts == 0 }) {
ContentUnavailableView(
"No Data",
systemImage: "chart.xyaxis.line",
description: Text(workouts.contains(where: \.completed)
? "No workouts in this time range. Try a wider range."
: "Complete workouts and your trends build up here."))
.frame(height: 220)
} else {
Chart(points) { point in
if metric == .workouts {
BarMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.foregroundStyle(metric.color.gradient)
.cornerRadius(3)
} else {
LineMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(metric.color.gradient)
PointMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.symbolSize(30)
.foregroundStyle(metric.color)
}
}
.chartYAxisLabel(unitLabel)
.chartXAxis {
AxisMarks(values: .automatic) { _ in
AxisGridLine()
AxisValueLabel(format: axisDateFormat)
}
}
.frame(height: 220)
}
}
private func value(of point: TrendPoint) -> Double {
switch metric {
case .workouts: Double(point.workouts)
case .volume: point.volume
case .time: point.activeMinutes
case .energy: point.energyKcal
}
}
private var unitLabel: String {
switch metric {
case .workouts: ""
case .volume: weightUnit.abbreviation
case .time: "min"
case .energy: "kcal"
}
}
private var axisDateFormat: Date.FormatStyle {
switch range.bucket {
case .month: .dateTime.month(.abbreviated)
default: .dateTime.month().day()
}
}
}