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

@@ -585,6 +585,14 @@ struct CalendarView: View {
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
workout: workoutVM.program(for: selectedDate),
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
workoutMissed: workoutVM.isWorkoutMissed(on: selectedDate),
workoutCompensation: workoutVM.isCompensation(on: selectedDate),
onWorkoutDone: { workoutVM.markWorkoutDone(on: selectedDate) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: selectedDate) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: selectedDate)
workoutVM.promptCompensation(forMissed: selectedDate)
},
onWorkoutTap: {
if let w = workoutVM.program(for: selectedDate),
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
@@ -1562,6 +1570,11 @@ struct DayTimelineView: View {
let calEvents: [EKEvent]
let workout: WorkoutProgram?
var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets
var workoutMissed: Bool = false
var workoutCompensation: Bool = false
var onWorkoutDone: (() -> Void)? = nil
var onWorkoutMissed: (() -> Void)? = nil
var onWorkoutCompensate: (() -> Void)? = nil
var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil
var onPin: ((TaskItem) -> Void)? = nil
@@ -1611,11 +1624,13 @@ struct DayTimelineView: View {
// date (workoutDates), never the program's shared live set state.
if let w = workout {
let done = workoutDone
let status = done ? "Done" : workoutMissed ? "Missed" : "Pending"
let kind = workoutCompensation ? "Compensation · " : ""
out.append(DayTLEntry(
timeLabel: "Workout",
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
color: AppColors.blue,
subtitle: "\(kind)\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s") · \(status)",
color: workoutMissed && !done ? AppColors.accent : AppColors.blue,
isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
))
}
@@ -1707,6 +1722,9 @@ struct DayTimelineView: View {
)
if let t = entry.taskRef {
row.contextMenu { taskContextMenu(t) }
} else if entry.workoutRef != nil,
onWorkoutDone != nil || onWorkoutMissed != nil {
row.contextMenu { workoutContextMenu }
} else if let ev = entry.eventRef,
onEditEvent != nil || onDeleteEvent != nil,
ev.calendar?.type != .subscription,
@@ -1735,6 +1753,27 @@ struct DayTimelineView: View {
)
}
// Quick actions for the day's workout applies to THIS date only.
@ViewBuilder
private var workoutContextMenu: some View {
if let onWorkoutDone {
Button { onWorkoutDone() } label: {
Label("Mark as Done", systemImage: "checkmark.circle")
}
}
if let onWorkoutMissed {
Button { onWorkoutMissed() } label: {
Label("Mark as Not Done", systemImage: "xmark.circle")
}
}
if let onWorkoutCompensate {
Divider()
Button { onWorkoutCompensate() } label: {
Label("Move to Rest Day / Compensate", systemImage: "arrow.uturn.forward")
}
}
}
// Edit / Delete for a real (non-subscription) calendar event.
@ViewBuilder
private func eventContextMenu(_ event: EKEvent) -> some View {

View File

@@ -91,6 +91,14 @@ struct TodayView: View {
calEvents: todayCalEvents,
workout: workoutVM.program(for: Date()),
workoutDone: workoutVM.isWorkoutComplete(on: Date()),
workoutMissed: workoutVM.isWorkoutMissed(on: Date()),
workoutCompensation: workoutVM.isCompensation(on: Date()),
onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: Date())
workoutVM.promptCompensation(forMissed: Date())
},
onWorkoutTap: {
if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
workoutVM.switchProgram(to: w.id)

View File

@@ -161,6 +161,17 @@ struct WorkoutView: View {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
.contextMenu {
Button {
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: true) }
} label: {
Label("Mark as Done", systemImage: "checkmark.circle")
}
Button {
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: false) }
} label: {
Label("Mark as Not Done", systemImage: "circle")
}
Divider()
Button(role: .destructive) {
withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) }
} label: {
@@ -762,6 +773,88 @@ struct WorkoutHistoryView: View {
}
}
// MARK: - Compensation Sheet ("missed make it up on a rest day")
struct CompensationSheet: View {
let missed: WorkoutViewModel.MissedDay
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
private let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
}()
private var restDays: [Date] { vm.upcomingRestDays() }
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 6) {
Image(systemName: "arrow.uturn.forward.circle")
.font(.system(size: 30, weight: .light))
.foregroundColor(AppColors.accent)
Text("Missed: \(missed.programName)")
.font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Text("Do you want to compensate for this workout on a rest day?")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center).padding(.horizontal, 24)
}
.padding(.top, 24).padding(.bottom, 16)
Divider().background(AppColors.border(cs))
if restDays.isEmpty {
Text("No free rest days in the next two weeks.")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).padding(.vertical, 30)
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ForEach(restDays, id: \.self) { day in
Button {
if let pid = missed.programId {
vm.scheduleCompensation(programId: pid, on: day)
}
dismiss()
} label: {
HStack {
Image(systemName: "moon.zzz")
.font(.system(size: 13)).foregroundColor(AppColors.accent)
.frame(width: 28)
Text(dayFmt.string(from: day))
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
Spacer()
Text("COMPENSATE")
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(AppColors.accentSoft).clipShape(Capsule())
}
.padding(.horizontal, 18).padding(.vertical, 13)
}
.buttonStyle(.plain)
if day != restDays.last { Divider().background(AppColors.border(cs)).padding(.leading, 46) }
}
}
}
}
Spacer(minLength: 0)
Button { dismiss() } label: {
Text("No, keep as missed")
.font(AppFonts.sans(14, weight: .semibold))
.foregroundColor(AppColors.text2(cs))
.frame(maxWidth: .infinity).padding(13)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain)
.padding(.horizontal, 20).padding(.bottom, 20).padding(.top, 8)
}
.background(AppColors.background(cs).ignoresSafeArea())
}
}
// MARK: - Rest Day Card
struct RestDayCard: View {
@Environment(\.colorScheme) private var cs