Workout: per-day history + log KC-8/KC-9
- Add per-day workout history: WorkoutDayLog/LoggedExercise/LoggedSet models stored at kisani.workout.history.<uid> (iCloud-synced). - snapshotDay(_:) captures the active program's completed sets on full completion and before the daily reset; only days with >=1 done set. - New WorkoutHistoryView (clock button in the Workout header) lists past days newest-first with per-exercise breakdown. - Log KC-8 (auto-switch / Rest Day) and KC-9 (history) in ISSUES.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -259,3 +259,53 @@ because the code is wrong. Checklist:
|
|||||||
and `ls <App>.app | grep LaunchScreen` (expect `LaunchScreen.storyboardc`).
|
and `ls <App>.app | grep LaunchScreen` (expect `LaunchScreen.storyboardc`).
|
||||||
|
|
||||||
Committed: fix `72f4566`, build-number bump `345415e`.
|
Committed: fix `72f4566`, build-number bump `345415e`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-8 — Workout doesn't follow the schedule; rest day shows a stale workout
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Workout
|
||||||
|
|
||||||
|
### Description
|
||||||
|
The Workout tab kept showing the last-used program with its old completion,
|
||||||
|
ignoring the weekly schedule. On a rest day (no program assigned) it still
|
||||||
|
displayed a fully-checked workout from a previous day.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
The daily reset (KC-1) cleared set completion but never changed the **active
|
||||||
|
program** — it stayed on whatever was last selected, regardless of the schedule.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- `applyScheduledProgramForToday()` (run from `resetSetsForNewDayIfNeeded` on a
|
||||||
|
new day) switches the active program to the one assigned to today.
|
||||||
|
- `isRestDayToday` (schedule exists but today has no entry) drives a new **Rest
|
||||||
|
Day** card in `WorkoutView` with a "Work out anyway" override; header reads
|
||||||
|
"Rest Day".
|
||||||
|
- Daily set reset (KC-1) unchanged.
|
||||||
|
|
||||||
|
Files: `ExerciseModels.swift`, `WorkoutView.swift`. Committed in `f9081cf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-9 — Per-day workout history
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||||
|
**Reported by:** User ("store the data for each day")
|
||||||
|
**Area:** Workout
|
||||||
|
|
||||||
|
### Description
|
||||||
|
Set completion resets daily, so there was no way to look back at what was
|
||||||
|
actually logged on a past day.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- New `WorkoutDayLog` / `LoggedExercise` / `LoggedSet` models; stored as
|
||||||
|
`kisani.workout.history.<uid>` (and iCloud-synced).
|
||||||
|
- `snapshotDay(_:)` captures the active program's completed sets — called from
|
||||||
|
`logWorkoutCompleted` (same-day) and from the daily reset **before** wiping
|
||||||
|
(captures partial days). Only records days with ≥1 set done.
|
||||||
|
- New `WorkoutHistoryView` (clock button in the Workout header) lists past days
|
||||||
|
newest-first with program, sets done, and per-exercise breakdown.
|
||||||
|
|
||||||
|
Files: `ExerciseModels.swift`, `WorkoutView.swift`.
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ struct WorkoutProgram: Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-day Workout History
|
||||||
|
|
||||||
|
/// A snapshot of what was logged on a given day, captured before the daily reset.
|
||||||
|
struct WorkoutDayLog: Identifiable, Codable {
|
||||||
|
var id: String { date } // "yyyy-MM-dd"
|
||||||
|
let date: String
|
||||||
|
let programName: String
|
||||||
|
let programEmoji: String
|
||||||
|
let exercises: [LoggedExercise]
|
||||||
|
|
||||||
|
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||||||
|
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoggedExercise: Identifiable, Codable {
|
||||||
|
var id = UUID()
|
||||||
|
let name: String
|
||||||
|
let sfSymbol: String
|
||||||
|
let sets: [LoggedSet]
|
||||||
|
var doneSets: Int { sets.filter(\.done).count }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoggedSet: Codable {
|
||||||
|
let weight: Double
|
||||||
|
let reps: Int
|
||||||
|
let done: Bool
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Exercise Template Library
|
// MARK: - Exercise Template Library
|
||||||
enum ExerciseMuscle: String, CaseIterable, Identifiable {
|
enum ExerciseMuscle: String, CaseIterable, Identifiable {
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
@@ -177,6 +205,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
@Published var activeProgramId: UUID
|
@Published var activeProgramId: UUID
|
||||||
@Published var activeSections: [WorkoutSection]
|
@Published var activeSections: [WorkoutSection]
|
||||||
@Published var workoutDates: [String] = []
|
@Published var workoutDates: [String] = []
|
||||||
|
@Published var history: [WorkoutDayLog] = []
|
||||||
@Published var restTimerSeconds: Int = 60
|
@Published var restTimerSeconds: Int = 60
|
||||||
@Published var restTimerEnabled: Bool = false
|
@Published var restTimerEnabled: Bool = false
|
||||||
@Published var restCountdown: Int = 0
|
@Published var restCountdown: Int = 0
|
||||||
@@ -209,6 +238,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
private let kActivePid: String
|
private let kActivePid: String
|
||||||
private let kWorkoutDates: String
|
private let kWorkoutDates: String
|
||||||
private let kLastActiveDay: String
|
private let kLastActiveDay: String
|
||||||
|
private let kWorkoutHistory: String
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||||
@@ -217,6 +247,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||||
|
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||||
|
|
||||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
||||||
@@ -286,6 +317,10 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
activeSections = blank.sections
|
activeSections = blank.sections
|
||||||
}
|
}
|
||||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||||
|
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||||
|
let saved = try? JSONDecoder().decode([WorkoutDayLog].self, from: hData) {
|
||||||
|
history = saved
|
||||||
|
}
|
||||||
resetSetsForNewDayIfNeeded()
|
resetSetsForNewDayIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +333,10 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||||
|
if let hData = try? JSONEncoder().encode(history) {
|
||||||
|
UserDefaults.kisani.set(hData, forKey: kWorkoutHistory)
|
||||||
|
CloudSyncManager.shared.push(data: hData, forKey: kWorkoutHistory)
|
||||||
|
}
|
||||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||||
@@ -313,13 +352,36 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
let today = iso.string(from: Date())
|
let today = iso.string(from: Date())
|
||||||
let last = UserDefaults.kisani.string(forKey: kLastActiveDay)
|
let last = UserDefaults.kisani.string(forKey: kLastActiveDay)
|
||||||
guard last != today else { return }
|
guard last != today else { return }
|
||||||
if last != nil {
|
if let last = last { // skip on the very first launch
|
||||||
clearAllDoneSets() // skip the wipe on the very first launch
|
snapshotDay(last) // save what was logged before wiping it
|
||||||
|
clearAllDoneSets()
|
||||||
applyScheduledProgramForToday() // follow the weekly schedule on a new day
|
applyScheduledProgramForToday() // follow the weekly schedule on a new day
|
||||||
}
|
}
|
||||||
UserDefaults.kisani.set(today, forKey: kLastActiveDay)
|
UserDefaults.kisani.set(today, forKey: kLastActiveDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// History newest-first, for the history view.
|
||||||
|
var historySorted: [WorkoutDayLog] { history.sorted { $0.date > $1.date } }
|
||||||
|
|
||||||
|
/// Capture the active program's completed work for `dayKey` into history.
|
||||||
|
/// Only records days where at least one set was done; updates in place.
|
||||||
|
func snapshotDay(_ dayKey: String) {
|
||||||
|
let logged: [LoggedExercise] = activeSections.flatMap { section in
|
||||||
|
section.exercises.map { ex in
|
||||||
|
LoggedExercise(name: ex.name, sfSymbol: ex.sfSymbol,
|
||||||
|
sets: ex.sets.map { LoggedSet(weight: $0.weight, reps: $0.reps, done: $0.isDone) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard logged.contains(where: { $0.doneSets > 0 }) else { return }
|
||||||
|
let log = WorkoutDayLog(date: dayKey,
|
||||||
|
programName: activeProgram?.name ?? "Workout",
|
||||||
|
programEmoji: activeProgram?.emoji ?? "dumbbell.fill",
|
||||||
|
exercises: logged)
|
||||||
|
history.removeAll { $0.date == dayKey }
|
||||||
|
history.append(log)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
/// 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()) }
|
||||||
|
|
||||||
@@ -357,6 +419,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
|
|
||||||
func logWorkoutCompleted(on date: Date = Date()) {
|
func logWorkoutCompleted(on date: Date = Date()) {
|
||||||
let key = iso.string(from: date)
|
let key = iso.string(from: date)
|
||||||
|
snapshotDay(key) // record today's session in history
|
||||||
guard !workoutDates.contains(key) else { return }
|
guard !workoutDates.contains(key) else { return }
|
||||||
workoutDates.append(key)
|
workoutDates.append(key)
|
||||||
save()
|
save()
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ struct WorkoutView: View {
|
|||||||
@EnvironmentObject var vm: WorkoutViewModel
|
@EnvironmentObject var vm: WorkoutViewModel
|
||||||
@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 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
|
||||||
@@ -100,6 +101,7 @@ struct WorkoutView: View {
|
|||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||||
IButton(icon: "ellipsis") { showManage = true }
|
IButton(icon: "ellipsis") { showManage = true }
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -286,6 +288,11 @@ struct WorkoutView: View {
|
|||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showHistory) {
|
||||||
|
WorkoutHistoryView().environmentObject(vm)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showAddSection) {
|
.sheet(isPresented: $showAddSection) {
|
||||||
AddSectionSheet().environmentObject(vm)
|
AddSectionSheet().environmentObject(vm)
|
||||||
.presentationDetents([.height(240)])
|
.presentationDetents([.height(240)])
|
||||||
@@ -552,6 +559,77 @@ struct DotProgressCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout History
|
||||||
|
struct WorkoutHistoryView: View {
|
||||||
|
@EnvironmentObject var vm: WorkoutViewModel
|
||||||
|
@Environment(\.colorScheme) var cs
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
private let parser: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||||
|
}()
|
||||||
|
private let pretty: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func label(_ key: String) -> String {
|
||||||
|
guard let d = parser.date(from: key) else { return key }
|
||||||
|
if Calendar.current.isDateInToday(d) { return "Today" }
|
||||||
|
if Calendar.current.isDateInYesterday(d) { return "Yesterday" }
|
||||||
|
return pretty.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Workout History").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, 14)
|
||||||
|
Divider().background(AppColors.border(cs))
|
||||||
|
|
||||||
|
if vm.historySorted.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||||
|
Text("No history yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||||
|
Text("Completed workouts are saved here each day.")
|
||||||
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||||
|
} else {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(vm.historySorted) { day in
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgramIcon(name: day.programEmoji, size: 14, color: AppColors.accent)
|
||||||
|
Text(day.programName).font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||||
|
Spacer()
|
||||||
|
Text(label(day.date)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
Text("\(day.doneSets)/\(day.totalSets) sets")
|
||||||
|
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
|
||||||
|
ForEach(day.exercises) { ex in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: ex.sfSymbol).font(.system(size: 11)).foregroundColor(AppColors.text3(cs)).frame(width: 18)
|
||||||
|
Text(ex.name).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||||||
|
Spacer()
|
||||||
|
Text("\(ex.doneSets)/\(ex.sets.count)").font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14).cardStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppColors.background(cs).ignoresSafeArea())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Rest Day Card
|
// MARK: - Rest Day Card
|
||||||
struct RestDayCard: View {
|
struct RestDayCard: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
|
|||||||
Reference in New Issue
Block a user