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

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