diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 3ca3ddb..84abdd2 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index e5e5c1c..afe0695 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -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.. 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