Add Workout Stats screen (daily/weekly/monthly) — KC-14
Aggregate the per-day workout logs into performance stats: - WorkoutDayLog.volume + StatPeriod/WorkoutStat/StatBucket. - WorkoutViewModel.stat/statInterval/statBuckets (current vs previous). - WorkoutStatsView: Day/Week/Month selector, Workouts/Sets/Volume cards with %-delta vs previous period, metric chooser, and a Swift Charts bar trend. Reached via a chart button in the Workout header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -429,3 +429,32 @@ Should behave like TickTick: one task, one next occurrence, one source of truth.
|
|||||||
|
|
||||||
Files: `TaskItem.swift`, `TodayView.swift`, `MatrixView.swift`.
|
Files: `TaskItem.swift`, `TodayView.swift`, `MatrixView.swift`.
|
||||||
Commits: `b4e36db` (lists + icons), `2f1bc5d` (Matrix).
|
Commits: `b4e36db` (lists + icons), `2f1bc5d` (Matrix).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-14 — Workout Stats (daily / weekly / monthly performance)
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Workout
|
||||||
|
|
||||||
|
### Description
|
||||||
|
Per-day logs (KC-9) existed but weren't aggregated, so there was no way to compare
|
||||||
|
daily/weekly/monthly performance.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- `WorkoutDayLog.volume` (Σ weight×reps over done sets); `StatPeriod`/`WorkoutStat`/
|
||||||
|
`StatBucket`.
|
||||||
|
- `WorkoutViewModel`: `stat(period, offset:)` (current vs previous), `statInterval`,
|
||||||
|
`statBuckets(period, count:)` for charting.
|
||||||
|
- New `WorkoutStatsView` (chart-bar button in the Workout header): Day/Week/Month
|
||||||
|
selector, summary cards (Workouts / Sets / Volume) with %-delta vs the previous
|
||||||
|
period, a metric chooser, and a Swift Charts bar trend (last 14 days / 8 weeks /
|
||||||
|
6 months). Empty state when no history.
|
||||||
|
|
||||||
|
Files: `ExerciseModels.swift`, `WorkoutView.swift`.
|
||||||
|
|
||||||
|
### Note
|
||||||
|
Stats are computed from the app's own per-day logs. Health (steps/kcal/resting HR +
|
||||||
|
workout dates) is already read on the Today dashboard and merged into the streak;
|
||||||
|
a Health overlay on this screen could be added later.
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ struct WorkoutDayLog: Identifiable, Codable {
|
|||||||
|
|
||||||
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||||||
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
||||||
|
/// Total training volume in kg for completed sets (Σ weight × reps).
|
||||||
|
var volume: Double {
|
||||||
|
exercises.reduce(0) { sum, ex in
|
||||||
|
sum + ex.sets.reduce(0) { $0 + ($1.done ? $1.weight * Double($1.reps) : 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout stats aggregation
|
||||||
|
|
||||||
|
enum StatPeriod: String, CaseIterable, Identifiable {
|
||||||
|
case day = "Day", week = "Week", month = "Month"
|
||||||
|
var id: String { rawValue }
|
||||||
|
var calComponent: Calendar.Component {
|
||||||
|
switch self { case .day: return .day; case .week: return .weekOfYear; case .month: return .month }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkoutStat {
|
||||||
|
var workouts: Int = 0
|
||||||
|
var sets: Int = 0
|
||||||
|
var volume: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatBucket: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let stat: WorkoutStat
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoggedExercise: Identifiable, Codable {
|
struct LoggedExercise: Identifiable, Codable {
|
||||||
@@ -382,6 +410,47 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats (daily / weekly / monthly performance)
|
||||||
|
|
||||||
|
/// History logs whose date falls inside `interval`.
|
||||||
|
private func logs(in interval: DateInterval) -> [WorkoutDayLog] {
|
||||||
|
history.filter { guard let d = iso.date(from: $0.date) else { return false }
|
||||||
|
return interval.contains(d) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calendar interval for `period`, shifted back by `offset` periods (0 = current).
|
||||||
|
func statInterval(_ period: StatPeriod, offset: Int) -> DateInterval {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let anchor = cal.date(byAdding: period.calComponent, value: offset, to: Date()) ?? Date()
|
||||||
|
return cal.dateInterval(of: period.calComponent, for: anchor)
|
||||||
|
?? DateInterval(start: cal.startOfDay(for: anchor), duration: 86400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate workout stats for one period (offset 0 = current, -1 = previous).
|
||||||
|
func stat(_ period: StatPeriod, offset: Int = 0) -> WorkoutStat {
|
||||||
|
let logs = logs(in: statInterval(period, offset: offset))
|
||||||
|
return WorkoutStat(
|
||||||
|
workouts: logs.count,
|
||||||
|
sets: logs.reduce(0) { $0 + $1.doneSets },
|
||||||
|
volume: logs.reduce(0) { $0 + $1.volume }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series of `count` recent periods, oldest → newest, for charting.
|
||||||
|
func statBuckets(_ period: StatPeriod, count: Int) -> [StatBucket] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
switch period {
|
||||||
|
case .day: fmt.dateFormat = "EEE"
|
||||||
|
case .week: fmt.dateFormat = "MMM d"
|
||||||
|
case .month: fmt.dateFormat = "MMM"
|
||||||
|
}
|
||||||
|
return (0..<count).reversed().map { back in
|
||||||
|
let interval = statInterval(period, offset: -back)
|
||||||
|
return StatBucket(label: fmt.string(from: interval.start), stat: stat(period, offset: -back))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The program assigned to today in the weekly schedule (nil = rest day).
|
/// The program assigned to today in the weekly schedule (nil = rest day).
|
||||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
|
import Charts
|
||||||
|
|
||||||
// MARK: - Rest Timer Banner
|
// MARK: - Rest Timer Banner
|
||||||
private struct RestTimerBanner: View {
|
private struct RestTimerBanner: View {
|
||||||
@@ -62,6 +63,7 @@ struct WorkoutView: View {
|
|||||||
@State private var showManage = false
|
@State private var showManage = false
|
||||||
@State private var showAddSection = false
|
@State private var showAddSection = false
|
||||||
@State private var showHistory = false
|
@State private var showHistory = false
|
||||||
|
@State private var showStats = false
|
||||||
@State private var isReordering = false
|
@State private var isReordering = false
|
||||||
@State private var trainAnyway = false // override the rest-day state for today
|
@State private var trainAnyway = false // override the rest-day state for today
|
||||||
@ObservedObject private var tm = TutorialManager.shared
|
@ObservedObject private var tm = TutorialManager.shared
|
||||||
@@ -101,6 +103,7 @@ struct WorkoutView: View {
|
|||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||||
IButton(icon: "ellipsis") { showManage = true }
|
IButton(icon: "ellipsis") { showManage = true }
|
||||||
}
|
}
|
||||||
@@ -293,6 +296,11 @@ struct WorkoutView: View {
|
|||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showStats) {
|
||||||
|
WorkoutStatsView().environmentObject(vm)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showAddSection) {
|
.sheet(isPresented: $showAddSection) {
|
||||||
AddSectionSheet().environmentObject(vm)
|
AddSectionSheet().environmentObject(vm)
|
||||||
.presentationDetents([.height(240)])
|
.presentationDetents([.height(240)])
|
||||||
@@ -559,6 +567,130 @@ struct DotProgressCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Stats
|
||||||
|
struct WorkoutStatsView: View {
|
||||||
|
@EnvironmentObject var vm: WorkoutViewModel
|
||||||
|
@Environment(\.colorScheme) var cs
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var period: StatPeriod = .week
|
||||||
|
@State private var metric: Metric = .volume
|
||||||
|
|
||||||
|
enum Metric: String, CaseIterable, Identifiable {
|
||||||
|
case volume = "Volume", sets = "Sets", workouts = "Workouts"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var current: WorkoutStat { vm.stat(period, offset: 0) }
|
||||||
|
private var previous: WorkoutStat { vm.stat(period, offset: -1) }
|
||||||
|
private var buckets: [StatBucket] {
|
||||||
|
switch period {
|
||||||
|
case .day: return vm.statBuckets(.day, count: 14)
|
||||||
|
case .week: return vm.statBuckets(.week, count: 8)
|
||||||
|
case .month: return vm.statBuckets(.month, count: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func value(_ s: WorkoutStat) -> Double {
|
||||||
|
switch metric { case .volume: return s.volume; case .sets: return Double(s.sets); case .workouts: return Double(s.workouts) }
|
||||||
|
}
|
||||||
|
private static let num: NumberFormatter = {
|
||||||
|
let f = NumberFormatter(); f.numberStyle = .decimal; f.maximumFractionDigits = 0; return f
|
||||||
|
}()
|
||||||
|
private func volStr(_ v: Double) -> String { (Self.num.string(from: NSNumber(value: v)) ?? "0") + " kg" }
|
||||||
|
|
||||||
|
private func delta(_ cur: Double, _ prev: Double) -> (String, Bool)? {
|
||||||
|
guard prev > 0 else { return cur > 0 ? ("New", true) : nil }
|
||||||
|
let pct = (cur - prev) / prev * 100
|
||||||
|
if abs(pct) < 1 { return ("±0%", true) }
|
||||||
|
return (String(format: "%+.0f%%", pct), pct >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Workout Stats").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||||
|
|
||||||
|
Picker("", selection: $period) {
|
||||||
|
ForEach(StatPeriod.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented).padding(.horizontal, 20).padding(.bottom, 12)
|
||||||
|
|
||||||
|
Divider().background(AppColors.border(cs))
|
||||||
|
|
||||||
|
if vm.history.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "chart.bar.xaxis").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||||
|
Text("No data yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||||
|
Text("Complete workouts and your stats appear here.")
|
||||||
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||||
|
} else {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
// ── Summary cards (this period vs last) ──
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
statCard("Workouts", "\(current.workouts)", delta(Double(current.workouts), Double(previous.workouts)))
|
||||||
|
statCard("Sets", "\(current.sets)", delta(Double(current.sets), Double(previous.sets)))
|
||||||
|
}
|
||||||
|
statCard("Volume", volStr(current.volume), delta(current.volume, previous.volume), wide: true)
|
||||||
|
|
||||||
|
// ── Metric chooser ──
|
||||||
|
Picker("", selection: $metric) {
|
||||||
|
ForEach(Metric.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
// ── Trend chart ──
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("\(metric.rawValue.uppercased()) · LAST \(buckets.count) \(period.rawValue.uppercased())S")
|
||||||
|
.font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||||
|
Chart(buckets) { b in
|
||||||
|
BarMark(
|
||||||
|
x: .value("Period", b.label),
|
||||||
|
y: .value(metric.rawValue, value(b.stat))
|
||||||
|
)
|
||||||
|
.foregroundStyle(AppColors.accent.gradient)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.chartYAxis { AxisMarks(position: .leading) }
|
||||||
|
}
|
||||||
|
.padding(14).cardStyle()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppColors.background(cs).ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statCard(_ title: String, _ value: String, _ delta: (String, Bool)?, wide: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title.uppercased()).font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(value).font(AppFonts.mono(wide ? 24 : 20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||||
|
if let d = delta {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: d.1 ? "arrow.up.right" : "arrow.down.right").font(.system(size: 9, weight: .bold))
|
||||||
|
Text(d.0).font(AppFonts.mono(10, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(d.1 ? AppColors.green : AppColors.red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text("vs last \(period.rawValue.lowercased())").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14).cardStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Workout History
|
// MARK: - Workout History
|
||||||
struct WorkoutHistoryView: View {
|
struct WorkoutHistoryView: View {
|
||||||
@EnvironmentObject var vm: WorkoutViewModel
|
@EnvironmentObject var vm: WorkoutViewModel
|
||||||
|
|||||||
Reference in New Issue
Block a user