From 966c1b35e9913ba3d0884749da6fac55647440cd Mon Sep 17 00:00:00 2001 From: kutesir Date: Sun, 7 Jun 2026 23:12:46 +0300 Subject: [PATCH] Workout: per-day history + log KC-8/KC-9 - Add per-day workout history: WorkoutDayLog/LoggedExercise/LoggedSet models stored at kisani.workout.history. (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 --- KisaniCal/ISSUES.md | 50 +++++++++++++++++ KisaniCal/Models/ExerciseModels.swift | 67 ++++++++++++++++++++++- KisaniCal/Views/WorkoutView.swift | 78 +++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 65f9444..b0788ab 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -259,3 +259,53 @@ because the code is wrong. Checklist: and `ls .app | grep LaunchScreen` (expect `LaunchScreen.storyboardc`). 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.` (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`. diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 51603d1..d7c41cf 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.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 enum ExerciseMuscle: String, CaseIterable, Identifiable { var id: String { rawValue } @@ -177,6 +205,7 @@ class WorkoutViewModel: ObservableObject { @Published var activeProgramId: UUID @Published var activeSections: [WorkoutSection] @Published var workoutDates: [String] = [] + @Published var history: [WorkoutDayLog] = [] @Published var restTimerSeconds: Int = 60 @Published var restTimerEnabled: Bool = false @Published var restCountdown: Int = 0 @@ -209,6 +238,7 @@ class WorkoutViewModel: ObservableObject { private let kActivePid: String private let kWorkoutDates: String private let kLastActiveDay: String + private let kWorkoutHistory: String init() { let uid = AuthManager.shared.currentUser?.id ?? "shared" @@ -217,6 +247,7 @@ class WorkoutViewModel: ObservableObject { self.kActivePid = "kisani.workout.activePid.\(uid)" self.kWorkoutDates = "kisani.workout.completedDates.\(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) CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)") @@ -286,6 +317,10 @@ class WorkoutViewModel: ObservableObject { activeSections = blank.sections } 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() } @@ -298,6 +333,10 @@ class WorkoutViewModel: ObservableObject { UserDefaults.kisani.set(schedDict, forKey: kSchedule) UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid) 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: activeProgramId.uuidString, forKey: kActivePid) CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates) @@ -313,13 +352,36 @@ class WorkoutViewModel: ObservableObject { let today = iso.string(from: Date()) let last = UserDefaults.kisani.string(forKey: kLastActiveDay) guard last != today else { return } - if last != nil { - clearAllDoneSets() // skip the wipe on the very first launch + if let last = last { // skip on the very first launch + snapshotDay(last) // save what was logged before wiping it + clearAllDoneSets() applyScheduledProgramForToday() // follow the weekly schedule on a new day } 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). var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) } @@ -357,6 +419,7 @@ class WorkoutViewModel: ObservableObject { func logWorkoutCompleted(on date: Date = Date()) { let key = iso.string(from: date) + snapshotDay(key) // record today's session in history guard !workoutDates.contains(key) else { return } workoutDates.append(key) save() diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 8214164..a1062ae 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -61,6 +61,7 @@ struct WorkoutView: View { @EnvironmentObject var vm: WorkoutViewModel @State private var showManage = false @State private var showAddSection = false + @State private var showHistory = false @State private var isReordering = false @State private var trainAnyway = false // override the rest-day state for today @ObservedObject private var tm = TutorialManager.shared @@ -100,6 +101,7 @@ struct WorkoutView: View { .foregroundColor(AppColors.text3(cs)) } Spacer() + IButton(icon: "clock.arrow.circlepath") { showHistory = true } IButton(icon: "ellipsis") { showManage = true } } .listRowBackground(Color.clear) @@ -286,6 +288,11 @@ struct WorkoutView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $showHistory) { + WorkoutHistoryView().environmentObject(vm) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $showAddSection) { AddSectionSheet().environmentObject(vm) .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 struct RestDayCard: View { @Environment(\.colorScheme) private var cs