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:
kutesir
2026-06-10 23:06:58 +03:00
parent f252fc7707
commit 443fe8504f
7 changed files with 367 additions and 7 deletions

View File

@@ -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 })