From d09a6c1fe3afcfbe7dbff013ac66bc22e2fa3bd0 Mon Sep 17 00:00:00 2001 From: kutesir Date: Mon, 8 Jun 2026 00:29:48 +0300 Subject: [PATCH] Fix: per-date workout completion in calendar (KC-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar marked every occurrence of a program complete once any one was finished — including future dates — because DayTimelineView read the program's live shared doneSets. - Add WorkoutViewModel.isWorkoutComplete(on:) backed by workoutDates. - DayTimelineView takes workoutDone and uses it instead of live sets; Calendar passes the selected date's state, Today passes today's. - A date now shows complete only if that specific date was finished. Log KC-10 in ISSUES.md. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/ISSUES.md | 30 +++++++++++++++++++++++++++ KisaniCal/Models/ExerciseModels.swift | 7 +++++++ KisaniCal/Views/CalendarView.swift | 7 +++++-- KisaniCal/Views/TodayView.swift | 1 + 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index b0788ab..39e2f62 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -309,3 +309,33 @@ actually logged on a past day. newest-first with program, sets done, and per-exercise breakdown. Files: `ExerciseModels.swift`, `WorkoutView.swift`. + +--- + +## KC-10 — Calendar marks future workouts complete (per-type, not per-date) + +**Status:** In Progress (implemented & build-verified, pending device build) +**Reported by:** User +**Area:** Calendar / Workout + +### Description +In the calendar, completing one occurrence of a program (e.g. Tuesday Shoulders) +showed **every** occurrence of that program as completed — including **future** +days (Saturday Shoulders) — with a checkmark + strikethrough. + +### Root cause +`DayTimelineView` computed the workout's completion from the program's **live** +shared set state: `w.doneSets == w.totalSets`. `program(for: date)` returns the +same program object for every date it's scheduled, so its in-progress completion +leaked onto all of those dates. + +### Fix +- Added `WorkoutViewModel.isWorkoutComplete(on:)` — checks `workoutDates` (the set + of dates a workout was actually finished/Health-detected), i.e. **per-date**. +- `DayTimelineView` now takes a `workoutDone: Bool` and uses it instead of the live + `doneSets`. Calendar passes `isWorkoutComplete(on: selectedDate)`, Today passes + `isWorkoutComplete(on: today)`. +- Result: a date shows the workout complete only if that **specific date** was + finished. Future occurrences are never auto-completed. + +Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`. diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index d7c41cf..e5e5c1c 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -485,6 +485,13 @@ class WorkoutViewModel: ObservableObject { /// or detected from Apple Health (both land in `workoutDates`). var isTodayRecorded: Bool { workoutDates.contains(iso.string(from: Date())) } + /// Whether a workout was actually completed on a specific calendar date. + /// Per-date (from `workoutDates`), NOT the program's live set state — so + /// finishing one occurrence never marks other days of the same program. + func isWorkoutComplete(on date: Date) -> Bool { + workoutDates.contains(iso.string(from: date)) + } + // MARK: - Program management func switchProgram(to id: UUID) { syncBack() diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 67590d3..60ec03e 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -587,6 +587,7 @@ struct CalendarView: View { }, calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [], workout: workoutVM.program(for: selectedDate), + workoutDone: workoutVM.isWorkoutComplete(on: selectedDate), onWorkoutTap: { if let w = workoutVM.program(for: selectedDate), workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) } @@ -1538,6 +1539,7 @@ struct DayTimelineView: View { var overdueTasks: [TaskItem] = [] let calEvents: [EKEvent] let workout: WorkoutProgram? + var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets var onWorkoutTap: (() -> Void)? = nil var onToggle: ((TaskItem) -> Void)? = nil var onPin: ((TaskItem) -> Void)? = nil @@ -1583,9 +1585,10 @@ struct DayTimelineView: View { )) } - // Workout — always pinned first (sortGroup -1) + // Workout — always pinned first (sortGroup -1). Completion is tied to THIS + // date (workoutDates), never the program's shared live set state. if let w = workout { - let done = w.doneSets == w.totalSets && w.totalSets > 0 + let done = workoutDone out.append(DayTLEntry( timeLabel: "Workout", title: w.name, diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 1ecdd26..d066535 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -90,6 +90,7 @@ struct TodayView: View { overdueTasks: [], calEvents: todayCalEvents, workout: workoutVM.program(for: Date()), + workoutDone: workoutVM.isWorkoutComplete(on: Date()), onWorkoutTap: { if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id)