Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -2404,3 +2404,54 @@ button) is only ever rendered when `isDoneToday`/`isMissedToday` are both
|
||||
false, so a watch-logged day already hides Finish entirely rather than
|
||||
needing a runtime guard against double-logging — the real gap was the
|
||||
CloudSync race above, not the button itself.
|
||||
|
||||
## KC-68 — Bring back Undo, but only for manually-logged days
|
||||
|
||||
Status: Implemented (not build-verified — no iOS runtime here)
|
||||
Reported by: User — tested Finish Workout to confirm KC-67 and got stuck
|
||||
with no way back since KC-67 removed Undo entirely
|
||||
Area: Workout (`DotProgressCard`, `WorkoutViewModel`)
|
||||
|
||||
### Description
|
||||
KC-67 removed Undo outright on the reasoning "Watch/Health is the source of
|
||||
truth once a day is logged." That's correct for a Watch/Health-detected
|
||||
day, but it also blocked undoing a day the user logged manually through
|
||||
the in-app Finish button (e.g. while testing) — there was no way back
|
||||
short of waiting for a new calendar day. User wants Undo back, but only
|
||||
when the day wasn't Watch/Health-sourced, and confirmed first.
|
||||
|
||||
### Change
|
||||
- `workoutDates` (the lifetime tally) didn't previously track *how* a day
|
||||
was logged. Added `manualWorkoutDates: [String]` (own storage key
|
||||
`kisani.workout.manualDates.<uid>`, loaded/saved/pushed to iCloud
|
||||
alongside the other workout keys) — the subset of `workoutDates` logged
|
||||
via the in-app Finish button.
|
||||
- `logWorkoutCompleted` (only ever called from the manual `markWorkoutDone`
|
||||
path, never from `syncFromHealthKit`) now also records the date into
|
||||
`manualWorkoutDates`. If the day is already in `workoutDates` (e.g.
|
||||
Watch/Health got there first), the existing dedup guard makes this whole
|
||||
function a no-op — so a Watch-logged day can never retroactively become
|
||||
"manual."
|
||||
- Added `WorkoutViewModel.isManuallyLogged(on:)` and restored
|
||||
`unmarkWorkoutDone(on:)` (removes from both `workoutDates` and
|
||||
`manualWorkoutDates`).
|
||||
- `DotProgressCard` gained `isManuallyLogged`/`onUndo` params back. Undo is
|
||||
now shown only when `isDoneToday && isManuallyLogged` (a Watch/Health day
|
||||
still shows nothing extra below the dots, per KC-67), and tapping it
|
||||
goes through a new `.undo` case in the existing `confirmationDialog`
|
||||
("Undo today's logged workout? ... won't affect anything recorded by
|
||||
Health or your Watch") rather than acting immediately — the bare Undo
|
||||
button KC-67 removed had no confirmation at all.
|
||||
|
||||
Files: `KisaniCal/Models/ExerciseModels.swift`,
|
||||
`KisaniCal/Views/WorkoutView.swift`.
|
||||
|
||||
### Note
|
||||
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
|
||||
parens confirmed per-file. Deliberately did NOT extend the KC-67 iCloud
|
||||
union-merge fix to the new `manualDates` key — unlike the lifetime tally,
|
||||
this set is meant to shrink when the user undoes, and merging it
|
||||
across-devices-only-grows would make an undo silently un-do itself on
|
||||
another device. Worst case for this key without that protection is a rare
|
||||
multi-device timing quirk in whether Undo is offered, not a data-integrity
|
||||
bug like KC-67's.
|
||||
|
||||
@@ -233,6 +233,10 @@ class WorkoutViewModel: ObservableObject {
|
||||
@Published var activeProgramId: UUID
|
||||
@Published var activeSections: [WorkoutSection]
|
||||
@Published var workoutDates: [String] = []
|
||||
/// Subset of `workoutDates` logged via the in-app Finish button rather than
|
||||
/// detected from Apple Health/Watch — only these are eligible for Undo, since
|
||||
/// Watch/Health is the source of truth once it has recorded a day itself.
|
||||
@Published var manualWorkoutDates: [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
|
||||
@@ -324,6 +328,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
private let kSchedule: String
|
||||
private let kActivePid: String
|
||||
private let kWorkoutDates: String
|
||||
private let kManualWorkoutDates: String
|
||||
private let kLastActiveDay: String
|
||||
private let kWorkoutHistory: String
|
||||
private let kMissedDates: String
|
||||
@@ -335,6 +340,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
self.kSchedule = "kisani.workout.schedule.\(uid)"
|
||||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||
self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
|
||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
||||
@@ -346,6 +352,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.manualDates.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.history.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
|
||||
@@ -412,6 +419,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
activeSections = blank.sections
|
||||
}
|
||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||
manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
|
||||
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
||||
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
||||
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||
@@ -430,6 +438,7 @@ 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(manualWorkoutDates, forKey: kManualWorkoutDates)
|
||||
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
||||
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
||||
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
||||
@@ -441,6 +450,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||
CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
|
||||
}
|
||||
|
||||
// MARK: - Daily Reset
|
||||
@@ -563,6 +573,10 @@ class WorkoutViewModel: ObservableObject {
|
||||
snapshotDay(key) // record today's session in history
|
||||
guard !workoutDates.contains(key) else { return }
|
||||
workoutDates.append(key)
|
||||
// Only called from the manual Finish path (never from the HealthKit sync
|
||||
// below), so a day only lands here — undo-eligible — when the app itself
|
||||
// is the one claiming it, not Watch/Health.
|
||||
if !manualWorkoutDates.contains(key) { manualWorkoutDates.append(key) }
|
||||
save()
|
||||
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
|
||||
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
|
||||
@@ -633,6 +647,13 @@ class WorkoutViewModel: ObservableObject {
|
||||
workoutDates.contains(iso.string(from: date))
|
||||
}
|
||||
|
||||
/// True only if THIS date's completion came from the in-app Finish button —
|
||||
/// the one case where Undo is offered. A Watch/Health-detected day is never
|
||||
/// undo-eligible, since that record lives outside the app.
|
||||
func isManuallyLogged(on date: Date) -> Bool {
|
||||
manualWorkoutDates.contains(iso.string(from: date))
|
||||
}
|
||||
|
||||
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
|
||||
|
||||
enum WorkoutDayStatus { case none, pending, done, missed }
|
||||
@@ -655,6 +676,16 @@ class WorkoutViewModel: ObservableObject {
|
||||
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
|
||||
}
|
||||
|
||||
/// Undo a manually-logged "done" mark — back to pending. Only ever offered
|
||||
/// for days in `manualWorkoutDates`; a Watch/Health-sourced day has no undo
|
||||
/// path here (that record isn't this app's to remove).
|
||||
func unmarkWorkoutDone(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
workoutDates.removeAll { $0 == key }
|
||||
manualWorkoutDates.removeAll { $0 == key }
|
||||
save()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -146,7 +146,9 @@ struct WorkoutView: View {
|
||||
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
|
||||
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
|
||||
onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
|
||||
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } }
|
||||
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
|
||||
isManuallyLogged: vm.isManuallyLogged(on: Date()),
|
||||
onUndo: { withAnimation(KisaniSpring.snappy) { vm.unmarkWorkoutDone(on: Date()) } }
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -598,11 +600,15 @@ struct DotProgressCard: View {
|
||||
let onMarkAllSets: () -> Void
|
||||
let onClearAllSets: () -> Void
|
||||
let onNotCompleted: () -> Void
|
||||
/// Whether today's "done" mark came from this app's own Finish button rather
|
||||
/// than Watch/Health — only manually-logged days offer an Undo (see body).
|
||||
let isManuallyLogged: Bool
|
||||
let onUndo: () -> Void
|
||||
|
||||
/// Every quick action that changes today's state is confirmed first — a
|
||||
/// stray tap here shouldn't silently rewrite the day.
|
||||
private enum PendingAction: Identifiable {
|
||||
case finish, checkAll, clearAll, notCompleted
|
||||
case finish, checkAll, clearAll, notCompleted, undo
|
||||
var id: Self { self }
|
||||
|
||||
var confirmLabel: String {
|
||||
@@ -611,9 +617,10 @@ struct DotProgressCard: View {
|
||||
case .checkAll: return "Check All"
|
||||
case .clearAll: return "Clear All"
|
||||
case .notCompleted: return "Mark Not Completed"
|
||||
case .undo: return "Undo"
|
||||
}
|
||||
}
|
||||
var isDestructive: Bool { self == .clearAll || self == .notCompleted }
|
||||
var isDestructive: Bool { self == .clearAll || self == .notCompleted || self == .undo }
|
||||
}
|
||||
@State private var pendingAction: PendingAction?
|
||||
|
||||
@@ -641,11 +648,14 @@ struct DotProgressCard: View {
|
||||
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
|
||||
}
|
||||
|
||||
// Watch/Health is the source of truth once today is logged — no
|
||||
// in-card way to undo that from here; the set checklist above is
|
||||
// only for confirming sets, not for contesting the logged day.
|
||||
// Watch/Health is the source of truth once it has logged a day —
|
||||
// no in-card way to undo that. A manually-logged day (this app's
|
||||
// own Finish button) is the one case Undo is still offered for,
|
||||
// confirmed first like every other state change here.
|
||||
if !(isDoneToday || isMissedToday) {
|
||||
actionRow
|
||||
} else if isDoneToday && isManuallyLogged {
|
||||
manualUndoRow
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
@@ -671,6 +681,7 @@ struct DotProgressCard: View {
|
||||
case .checkAll: return "Check every set as done?"
|
||||
case .clearAll: return "Clear all logged sets?"
|
||||
case .notCompleted: return "Mark today as not completed?"
|
||||
case .undo: return "Undo today's logged workout?"
|
||||
case nil: return ""
|
||||
}
|
||||
}
|
||||
@@ -687,6 +698,8 @@ struct DotProgressCard: View {
|
||||
return "This un-checks all \(total) sets. It won't undo today's logged workout."
|
||||
case .notCompleted:
|
||||
return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
|
||||
case .undo:
|
||||
return "This removes today's manually logged workout and goes back to pending. It won't affect anything recorded by Health or your Watch."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,6 +709,7 @@ struct DotProgressCard: View {
|
||||
case .checkAll: onMarkAllSets()
|
||||
case .clearAll: onClearAllSets()
|
||||
case .notCompleted: onNotCompleted()
|
||||
case .undo: onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +797,18 @@ struct DotProgressCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only shown for a manually-logged day — tapping this always confirms first
|
||||
/// (see `PendingAction.undo`), so a stray tap can't silently un-log a workout.
|
||||
private var manualUndoRow: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button { pendingAction = .undo } label: {
|
||||
Label("Undo", systemImage: "arrow.uturn.backward")
|
||||
.font(AppFonts.sans(11, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Stats
|
||||
|
||||
Reference in New Issue
Block a user