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:
@@ -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()) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user