Workout check-in: missed state + rest-day compensation (KC-16)
- Notification actions on workout reminders/check-ins: "Yes, I completed it", "No, I didn't" (marks the day missed, then asks to compensate on a rest day), "Remind me in 1 hour" (snooze). - VM: missedDates + compensations (persisted/synced), per-date markWorkoutDone/Missed, workoutStatus, upcomingRestDays, scheduleCompensation, promptCompensation, checkPendingMissed. program(for:) returns a compensation program on its rest day. - CompensationSheet: pick a rest day or keep as missed; compensation days get a one-off "Missed workout recovery" reminder. - Long-press menus: workout row (Done / Not Done / Compensate) in Today + Calendar; exercise cards (Mark as Done / Not Done). All actions are per-date — one occurrence never affects another. - Workout row shows Pending / Done / Missed + "Compensation" label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -233,7 +233,16 @@ class WorkoutViewModel: ObservableObject {
|
||||
@Published var activeProgramId: UUID
|
||||
@Published var activeSections: [WorkoutSection]
|
||||
@Published var workoutDates: [String] = []
|
||||
@Published var missedDates: [String] = [] // days marked "didn't do it"
|
||||
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
|
||||
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
|
||||
@Published var history: [WorkoutDayLog] = []
|
||||
|
||||
struct MissedDay: Identifiable {
|
||||
let id: String // "yyyy-MM-dd" of the missed day
|
||||
let programId: UUID? // what was scheduled that day
|
||||
let programName: String
|
||||
}
|
||||
@Published var restTimerSeconds: Int = 60
|
||||
@Published var restTimerEnabled: Bool = false
|
||||
@Published var restCountdown: Int = 0
|
||||
@@ -267,6 +276,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
private let kWorkoutDates: String
|
||||
private let kLastActiveDay: String
|
||||
private let kWorkoutHistory: String
|
||||
private let kMissedDates: String
|
||||
private let kCompensations: String
|
||||
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
@@ -276,6 +287,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
||||
self.kCompensations = "kisani.workout.compensations.\(uid)"
|
||||
|
||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
||||
@@ -345,6 +358,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
activeSections = blank.sections
|
||||
}
|
||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
||||
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
||||
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||
let saved = try? JSONDecoder().decode([WorkoutDayLog].self, from: hData) {
|
||||
history = saved
|
||||
@@ -361,6 +376,10 @@ class WorkoutViewModel: ObservableObject {
|
||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
||||
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
||||
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
||||
CloudSyncManager.shared.push(value: compensations, forKey: kCompensations)
|
||||
if let hData = try? JSONEncoder().encode(history) {
|
||||
UserDefaults.kisani.set(hData, forKey: kWorkoutHistory)
|
||||
CloudSyncManager.shared.push(data: hData, forKey: kWorkoutHistory)
|
||||
@@ -454,9 +473,9 @@ class WorkoutViewModel: ObservableObject {
|
||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||
|
||||
/// True when a schedule exists but today has no program — a rest day.
|
||||
/// A compensation scheduled for today counts as a workout day, not rest.
|
||||
var isRestDayToday: Bool {
|
||||
let weekday = Calendar.current.component(.weekday, from: Date())
|
||||
return !schedule.isEmpty && schedule[weekday] == nil
|
||||
!schedule.isEmpty && program(for: Date()) == nil
|
||||
}
|
||||
|
||||
/// On a new day, make the active program match the day's schedule so the
|
||||
@@ -560,6 +579,79 @@ class WorkoutViewModel: ObservableObject {
|
||||
workoutDates.contains(iso.string(from: date))
|
||||
}
|
||||
|
||||
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
|
||||
|
||||
enum WorkoutDayStatus { case none, pending, done, missed }
|
||||
|
||||
func isWorkoutMissed(on date: Date) -> Bool { missedDates.contains(iso.string(from: date)) }
|
||||
func isCompensation(on date: Date) -> Bool { compensations[iso.string(from: date)] != nil }
|
||||
|
||||
/// Status of the workout scheduled on a specific date. Done wins over missed.
|
||||
func workoutStatus(on date: Date) -> WorkoutDayStatus {
|
||||
guard program(for: date) != nil else { return .none }
|
||||
if isWorkoutComplete(on: date) { return .done }
|
||||
if isWorkoutMissed(on: date) { return .missed }
|
||||
return .pending
|
||||
}
|
||||
|
||||
/// Mark one specific day's workout done — never touches other occurrences.
|
||||
func markWorkoutDone(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
missedDates.removeAll { $0 == key }
|
||||
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
|
||||
}
|
||||
|
||||
/// Mark one specific day's workout missed (undoes a done mark for that day only).
|
||||
func markWorkoutMissed(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
workoutDates.removeAll { $0 == key }
|
||||
if !missedDates.contains(key) { missedDates.append(key) }
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Compensation (recover a missed workout on a rest day)
|
||||
|
||||
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
||||
func upcomingRestDays(within days: Int = 14) -> [Date] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
return (1...days).compactMap { off -> Date? in
|
||||
guard let d = cal.date(byAdding: .day, value: off, to: today) else { return nil }
|
||||
let weekday = cal.component(.weekday, from: d)
|
||||
guard schedule[weekday] == nil, compensations[iso.string(from: d)] == nil else { return nil }
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleCompensation(programId: UUID, on date: Date) {
|
||||
compensations[iso.string(from: date)] = programId.uuidString
|
||||
save()
|
||||
}
|
||||
|
||||
func removeCompensation(on date: Date) {
|
||||
compensations.removeValue(forKey: iso.string(from: date))
|
||||
save()
|
||||
}
|
||||
|
||||
/// Begin the "compensate for this missed day?" flow for a given date.
|
||||
func promptCompensation(forMissed date: Date) {
|
||||
let prog = program(for: date)
|
||||
askCompensate = MissedDay(id: iso.string(from: date),
|
||||
programId: prog?.id,
|
||||
programName: prog?.name ?? "Workout")
|
||||
}
|
||||
|
||||
/// Picks up "No, I missed it" tapped on a workout notification (set in background).
|
||||
@MainActor
|
||||
func checkPendingMissed() {
|
||||
let key = "kisani.workout.pendingMissed"
|
||||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: key)
|
||||
guard let date = iso.date(from: dateStr) else { return }
|
||||
markWorkoutMissed(on: date)
|
||||
promptCompensation(forMissed: date)
|
||||
}
|
||||
|
||||
// MARK: - Program management
|
||||
func switchProgram(to id: UUID) {
|
||||
syncBack()
|
||||
@@ -597,6 +689,12 @@ class WorkoutViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func program(for date: Date) -> WorkoutProgram? {
|
||||
// A compensation scheduled on this (rest) day takes the slot.
|
||||
if let pidStr = compensations[iso.string(from: date)],
|
||||
let pid = UUID(uuidString: pidStr),
|
||||
let prog = programs.first(where: { $0.id == pid }) {
|
||||
return prog
|
||||
}
|
||||
let weekday = Calendar.current.component(.weekday, from: date)
|
||||
guard let pid = schedule[weekday] else { return nil }
|
||||
return programs.first { $0.id == pid }
|
||||
@@ -655,6 +753,19 @@ class WorkoutViewModel: ObservableObject {
|
||||
if wasMarkedDone && restTimerEnabled { startRestTimer() }
|
||||
}
|
||||
|
||||
/// Mark every set of one exercise done/undone (long-press quick action).
|
||||
func setExerciseDone(sectionId: UUID, exerciseId: UUID, done: Bool) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
else { return }
|
||||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||||
}
|
||||
if done, sessionStartDate == nil { sessionStartDate = Date() }
|
||||
syncBack()
|
||||
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||
}
|
||||
|
||||
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
|
||||
Reference in New Issue
Block a user