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
This commit is contained in:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
@@ -0,0 +1,101 @@
//
// AchievementsSection.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// The achievements badge grid. Every badge is a pure derivation of history
/// (`ProgressPlanner.achievements`) earned ones glow in their color, locked ones
/// wear a progress ring showing how close they are. Tapping shows the how-to-earn
/// detail.
struct AchievementsSection: View {
let achievements: [Achievement]
@State private var selected: Achievement?
private var earnedCount: Int { achievements.filter(\.earned).count }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Achievements")
.font(.headline)
Spacer()
Text("\(earnedCount) of \(achievements.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
spacing: 16) {
ForEach(achievements) { achievement in
Button {
selected = achievement
} label: {
AchievementBadge(achievement: achievement)
}
.buttonStyle(.plain)
}
}
}
.progressCard()
.alert(
selected?.title ?? "",
isPresented: Binding(
get: { selected != nil },
set: { if !$0 { selected = nil } }
),
presenting: selected
) { _ in
Button("OK") { selected = nil }
} message: { achievement in
Text(achievement.earned
? achievement.detail
: "\(achievement.detail)\(Int((achievement.progress * 100).rounded()))% there.")
}
}
}
/// One badge: a tinted circle with the symbol, the title beneath. Locked badges are
/// desaturated with a thin progress arc around the circle.
struct AchievementBadge: View {
let achievement: Achievement
private var color: Color { Color.color(from: achievement.colorName) }
var body: some View {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill))
Image(systemName: achievement.systemImage)
.font(.title3)
.foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel))
}
.frame(width: 52, height: 52)
.overlay {
if !achievement.earned, achievement.progress > 0 {
Circle()
.trim(from: 0, to: achievement.progress)
.stroke(color.opacity(0.6),
style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
}
}
Text(achievement.title)
.font(.caption2)
.multilineTextAlignment(.center)
.lineLimit(2, reservesSpace: true)
.foregroundStyle(achievement.earned ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
"\(achievement.title), \(achievement.earned ? "earned" : "locked"): \(achievement.detail)")
}
}
@@ -0,0 +1,144 @@
//
// ExerciseTrendsView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Per-exercise drill-down: every weighted exercise with completed history, most
/// recently trained first, each opening its weight-progression detail. This promotes
/// the progression chart to a first-class Progress surface (it also remains on the
/// library's reference pages).
struct ExerciseTrendsListView: View {
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Completed weighted logs, newest first.
@Query private var logs: [WorkoutLog]
init() {
let completedRaw = WorkoutStatus.completed.rawValue
let weightRaw = LoadType.weight.rawValue
_logs = Query(
filter: #Predicate<WorkoutLog> {
$0.statusRaw == completedRaw && $0.loadType == weightRaw
},
sort: \WorkoutLog.date,
order: .reverse)
}
private struct ExerciseSummary: Identifiable {
var name: String
var sessions: Int
var best: Double
var last: Date
var id: String { name }
}
/// One row per exercise name: session count, best top-set weight, last trained.
/// `logs` is newest-first, so the first log seen per name fixes `last`.
private var summaries: [ExerciseSummary] {
var byName: [String: ExerciseSummary] = [:]
for log in logs {
let top = log.setEntries?.compactMap(\.weight).max() ?? log.weight
if var summary = byName[log.exerciseName] {
summary.sessions += 1
summary.best = max(summary.best, top)
byName[log.exerciseName] = summary
} else {
byName[log.exerciseName] = ExerciseSummary(
name: log.exerciseName, sessions: 1, best: top, last: log.date)
}
}
return byName.values.sorted { $0.last > $1.last }
}
var body: some View {
List(summaries) { summary in
NavigationLink {
ExerciseTrendDetailView(exerciseName: summary.name)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(summary.name)
Text("\(summary.sessions) session\(summary.sessions == 1 ? "" : "s") · last \(summary.last.daysAgoLabel())")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(weightUnit.format(summary.best))
.font(.subheadline.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
}
.overlay {
if summaries.isEmpty {
ContentUnavailableView(
"No Weighted Exercises Yet",
systemImage: "dumbbell",
description: Text("Complete weighted sets and each exercise's progression shows up here."))
}
}
.navigationTitle("Exercise Trends")
}
}
/// One exercise's progression: headline stats over its completed history plus the
/// weight-progression chart.
struct ExerciseTrendDetailView: View {
let exerciseName: String
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Completed logs for this exercise, oldest first (matching the chart's order).
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
let completedRaw = WorkoutStatus.completed.rawValue
_logs = Query(
filter: #Predicate<WorkoutLog> {
$0.exerciseName == name && $0.statusRaw == completedRaw
},
sort: \WorkoutLog.date,
order: .forward)
}
private var bestWeight: Double {
logs.map { $0.setEntries?.compactMap(\.weight).max() ?? $0.weight }.max() ?? 0
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
ProgressStatTile(
title: "Best",
value: weightUnit.format(bestWeight),
unit: nil)
ProgressStatTile(
title: "Sessions",
value: "\(logs.count)",
unit: nil)
ProgressStatTile(
title: "Last",
value: logs.last?.date.daysAgoLabel() ?? "",
unit: nil)
}
WeightProgressionChartView(exerciseName: exerciseName)
.progressCard()
}
.padding(.horizontal)
}
.background(Color(.systemGroupedBackground))
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
}
}
+138
View File
@@ -0,0 +1,138 @@
//
// GoalTrackCard.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// One goal's adherence card: a 12-week strip (each square filled by that week's
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
/// member schedule. The goal is the ledger category this track continues unbroken
/// across schedule rewrites.
struct GoalTrackCard: View {
let track: GoalTrack
private var color: Color {
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(track.goal?.displayName ?? "Unassigned",
systemImage: track.goal?.systemImage ?? "tag")
.font(.headline)
.foregroundStyle(color)
Spacer()
if track.streakWeeks > 0 {
streakBadge
}
}
weekStrip
VStack(alignment: .leading, spacing: 10) {
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
scheduleRow(scheduleTrack)
}
}
}
.progressCard()
}
/// The streak flame: orange while the current week is still meetable, dimmed the
/// moment it can no longer be (the streak is at stake, not yet erased).
private var streakBadge: some View {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
Text("\(track.streakWeeks) wk")
.monospacedDigit()
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
}
private var weekStrip: some View {
HStack(spacing: 4) {
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(fill(for: week))
.frame(height: 16)
.overlay {
// Outline the in-flight week so "unfilled" reads as "not over
// yet" rather than "missed".
if index == track.weeks.count - 1 {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(stripSummary)
}
private func fill(for week: WeekMark) -> Color {
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
if week.fraction == 0 { return Color(.systemFill) }
return color.opacity(0.25 + 0.75 * week.fraction)
}
private var stripSummary: String {
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
let active = track.weeks.filter { !$0.isVacuous }.count
return "Hit \(hit) of the last \(active) weeks"
}
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
let schedule = scheduleTrack.schedule
return HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(schedule.routineName)
.font(.subheadline)
Text(schedule.recurrence.summary(
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
date: schedule.onceDate))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
thisWeekDots(scheduleTrack)
}
}
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
/// week adds a quiet "missed" tag informative, not scolding.
@ViewBuilder
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
if let week = scheduleTrack.currentWeek, !week.isVacuous {
HStack(spacing: 5) {
if scheduleTrack.currentWeekBroken {
Text("missed")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(0..<week.expected, id: \.self) { index in
Circle()
.fill(index < week.met ? color : Color.clear)
.overlay {
if index >= week.met {
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
}
}
.frame(width: 9, height: 9)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(week.met) of \(week.expected) this week")
} else {
Text("")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
@@ -0,0 +1,314 @@
//
// ProgressTabView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The Progress tab observe by goals (UX-REDESIGN.md). Per-goal adherence tracks
/// first, then this-week totals, aggregate trends, per-exercise drill-down, the
/// achievements grid, and full history (absorbed from the old Log tab). Everything on
/// this screen is derived by `ProgressPlanner` from the workout documents; nothing is
/// persisted, so it all survives cache rebuilds.
struct ProgressTabView: View {
/// Screenshot-capture affordance: `.bottom` opens the tab pre-scrolled to the
/// end (nil = the normal top). Only the DEBUG screenshot root passes it.
var initialAnchor: UnitPoint?
@Environment(SyncEngine.self) private var sync
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
var body: some View {
NavigationStack {
ScrollView {
let facts = workoutFacts
let tracks = ProgressPlanner.goalTracks(
schedules: scheduleFacts, workouts: facts,
goalOrder: GoalKind.orderedCases())
VStack(alignment: .leading, spacing: 16) {
WeekSummaryTiles(workouts: facts)
if tracks.isEmpty {
goalTracksHint
} else {
ForEach(tracks) { GoalTrackCard(track: $0) }
}
TrendsSection(workouts: facts)
exerciseTrendsLink
AchievementsSection(
achievements: ProgressPlanner.achievements(tracks: tracks, workouts: facts))
historySection
}
.padding(.horizontal)
.padding(.bottom, 24)
}
.defaultScrollAnchor(initialAnchor)
.background(Color(.systemGroupedBackground))
.navigationTitle("Progress")
}
}
// MARK: - Fact building (live-resolve IDs through the seed clone-on-edit redirect)
private var scheduleFacts: [ScheduleFacts] {
schedules.map { schedule in
ScheduleFacts(
id: schedule.id,
goal: schedule.goalKind,
recurrence: schedule.recurrenceEnum,
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
onceDate: schedule.date,
createdAt: schedule.createdAt,
routineID: sync.currentRoutineID(for: schedule.routineID),
routineName: schedule.routineName)
}
}
private var workoutFacts: [WorkoutFacts] {
workouts.map { workout in
WorkoutFacts(
routineID: workout.routineID.map { sync.currentRoutineID(for: $0) },
start: workout.start,
end: workout.end,
completed: workout.status == .completed,
volume: Self.volume(of: workout),
activeMinutes: workout.end.map { max(0, $0.timeIntervalSince(workout.start) / 60) } ?? 0,
energyKcal: workout.metricActiveEnergyKcal ?? 0)
}
}
/// Total lifted volume for one workout: the recorded watch metric when present,
/// else derived from per-set actuals, else the plan (the same fallback ladder the
/// documents' `effectiveSetEntries` uses).
static func volume(of workout: Workout) -> Double {
if let recorded = workout.metricTotalVolume { return recorded }
return workout.logs.reduce(0) { sum, log in
guard log.status == .completed, log.loadTypeEnum == .weight else { return sum }
if let entries = log.setEntries, !entries.isEmpty {
return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) }
}
return sum + Double(log.sets * log.reps) * log.weight
}
}
// MARK: - Sections
private var goalTracksHint: some View {
VStack(alignment: .leading, spacing: 6) {
Label("No Goal Tracks Yet", systemImage: "chart.bar.fill")
.font(.headline)
Text("Add schedules on the Today tab and each goal builds a weekly adherence track here.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.progressCard()
}
private var exerciseTrendsLink: some View {
NavigationLink {
ExerciseTrendsListView()
} label: {
HStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Exercise Trends")
.font(.headline)
.foregroundStyle(.primary)
Text("Weight progression for every exercise you've logged")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.progressCard()
}
.buttonStyle(.plain)
}
private var historySection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("History")
.font(.headline)
Spacer()
NavigationLink("All Workouts") {
WorkoutHistoryView()
}
.font(.subheadline)
}
if workouts.isEmpty {
Text("No workouts yet. Start one from the Today tab.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(workouts.prefix(3)) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
HStack {
CalendarListItem(
date: workout.start,
title: workout.routineName ?? Routine.unnamed,
subtitle: subtitle(for: workout),
subtitle2: workout.statusName)
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
.progressCard()
}
private func subtitle(for workout: Workout) -> String {
if workout.status == .completed, let endDate = workout.end {
return workout.start.humanTimeInterval(to: endDate)
}
return workout.start.formattedDate()
}
}
// MARK: - Shared card chrome
extension View {
/// The Progress tab's card treatment one shared background so every section
/// reads as part of the same system.
func progressCard() -> some View {
padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
// MARK: - This-week stat tiles
/// Three at-a-glance totals for the current calendar week, each with a delta arrow
/// against last week.
struct WeekSummaryTiles: View {
let workouts: [WorkoutFacts]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
var body: some View {
let calendar = Calendar.current
let now = Date()
let this = totals(in: calendar.dateInterval(of: .weekOfYear, for: now))
let last = totals(in: calendar.date(byAdding: .weekOfYear, value: -1, to: now)
.flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) })
HStack(spacing: 12) {
ProgressStatTile(
title: "This Week",
value: "\(this.workouts)",
unit: this.workouts == 1 ? "workout" : "workouts",
delta: Double(this.workouts - last.workouts),
deltaText: "\(abs(this.workouts - last.workouts))")
ProgressStatTile(
title: "Volume",
value: this.volume.formatted(.number.notation(.compactName).precision(.significantDigits(3))),
unit: weightUnit.abbreviation,
delta: this.volume - last.volume,
deltaText: abs(this.volume - last.volume)
.formatted(.number.notation(.compactName).precision(.significantDigits(2))))
ProgressStatTile(
title: "Active Time",
value: Self.hoursMinutes(this.minutes),
unit: nil,
delta: this.minutes - last.minutes,
deltaText: Self.hoursMinutes(abs(this.minutes - last.minutes)))
}
}
private func totals(in interval: DateInterval?) -> (workouts: Int, volume: Double, minutes: Double) {
guard let interval else { return (0, 0, 0) }
let matching = workouts.filter { $0.completed && interval.contains($0.start) }
return (matching.count,
matching.reduce(0) { $0 + $1.volume },
matching.reduce(0) { $0 + $1.activeMinutes })
}
static func hoursMinutes(_ minutes: Double) -> String {
let total = Int(minutes.rounded())
let (h, m) = (total / 60, total % 60)
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
}
/// One compact stat tile: caption, big value, and a vs-last-week arrow. Shared by the
/// week summary row and the exercise drill-down.
struct ProgressStatTile: View {
let title: String
let value: String
var unit: String?
/// Signed change vs the comparison period; only its sign is displayed.
var delta: Double?
/// Preformatted magnitude for the delta line (callers know their unit best).
var deltaText: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.7)
if let unit {
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let delta, delta != 0 {
Label("\(deltaText ?? fallbackDeltaText(delta)) vs last wk",
systemImage: delta > 0 ? "arrow.up" : "arrow.down")
.font(.caption2.weight(.medium))
.foregroundStyle(delta > 0 ? .green : .secondary)
} else {
// Keep tile heights equal whether or not a delta renders.
Text("")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private func fallbackDeltaText(_ delta: Double) -> String {
abs(delta).formatted(.number.notation(.compactName).precision(.significantDigits(2)))
}
}
+134
View File
@@ -0,0 +1,134 @@
//
// 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()
}
}
}