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`.
|
||||
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 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 {
|
||||
@@ -382,6 +410,47 @@ class WorkoutViewModel: ObservableObject {
|
||||
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).
|
||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AudioToolbox
|
||||
import Charts
|
||||
|
||||
// MARK: - Rest Timer Banner
|
||||
private struct RestTimerBanner: View {
|
||||
@@ -62,6 +63,7 @@ struct WorkoutView: View {
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var showHistory = false
|
||||
@State private var showStats = false
|
||||
@State private var isReordering = false
|
||||
@State private var trainAnyway = false // override the rest-day state for today
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
@@ -101,6 +103,7 @@ struct WorkoutView: View {
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||
IButton(icon: "ellipsis") { showManage = true }
|
||||
}
|
||||
@@ -293,6 +296,11 @@ struct WorkoutView: View {
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
WorkoutStatsView().environmentObject(vm)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showAddSection) {
|
||||
AddSectionSheet().environmentObject(vm)
|
||||
.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
|
||||
struct WorkoutHistoryView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
|
||||
Reference in New Issue
Block a user