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:
kutesir
2026-06-10 10:21:12 +03:00
parent c0eb1ffa23
commit 9768af0017
3 changed files with 230 additions and 0 deletions

View File

@@ -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.

View File

@@ -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()) }

View File

@@ -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