From c24bc9cfeb1c2eeadfb74eabbf425d27513d2bab Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 12 Jul 2026 00:37:05 -0400 Subject: [PATCH] File ad hoc workouts under their routine's goal on the Today board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ad hoc workout was only launched ad hoc — any schedule referencing the same routine (through the clone redirect) still says what the user trains it for. Infer the goal from those schedules, tie-breaking by the user's goal order; only truly unassigned workouts stay in Unassigned. Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv --- Workouts/Views/Today/TodayView.swift | 43 +++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/Workouts/Views/Today/TodayView.swift b/Workouts/Views/Today/TodayView.swift index 7ac2b03..d9cde39 100644 --- a/Workouts/Views/Today/TodayView.swift +++ b/Workouts/Views/Today/TodayView.swift @@ -63,7 +63,8 @@ struct TodayView: View { /// Workouts on the viewed day whose routine isn't already shown via a schedule row /// above — e.g. an ad hoc "Start Now" workout, or a routine whose schedule isn't due /// today. Matched the same way `todaysWorkout` matches a workout to a schedule: both - /// sides live-resolved through the seed clone-on-edit redirect. + /// sides live-resolved through the seed clone-on-edit redirect. Each is then filed + /// under its `inferredGoal(for:)` group, falling back to Unassigned. private var orphanWorkouts: [Workout] { let scheduledRoutineIDs = Set(visibleSchedules.map { sync.currentRoutineID(for: $0.routineID) }) return workouts.filter { workout in @@ -73,11 +74,16 @@ struct TodayView: View { } } - /// Non-empty goal groups in the user's chosen goal order. - private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] { - GoalKind.orderedCases().compactMap { kind in + /// Non-empty goal groups in the user's chosen goal order. A group exists when the + /// viewed day has due schedules for that goal, orphan workouts inferred into it + /// (see `inferredGoal(for:)`), or both — so an ad hoc strength run surfaces a + /// Strength header even on a day with no strength schedule due. + private var goalGroups: [(kind: GoalKind, schedules: [Schedule], orphans: [Workout])] { + let orphansByGoal = Dictionary(grouping: orphanWorkouts) { inferredGoal(for: $0) } + return GoalKind.orderedCases().compactMap { kind in let matches = visibleSchedules.filter { $0.goalKind == kind } - return matches.isEmpty ? nil : (kind, matches) + let orphans = orphansByGoal[kind] ?? [] + return (matches.isEmpty && orphans.isEmpty) ? nil : (kind, matches, orphans) } } @@ -86,6 +92,28 @@ struct TodayView: View { visibleSchedules.filter { $0.goalKind == nil } } + /// Orphan workouts whose routine no schedule anywhere assigns a goal. + private var unassignedOrphanWorkouts: [Workout] { + orphanWorkouts.filter { inferredGoal(for: $0) == nil } + } + + /// The goal an orphan workout belongs to: the goal of any schedule — due today or + /// not — referencing the same routine (through the clone redirect on both sides). + /// The workout was "ad hoc" only in how it was launched; the routine's schedule + /// still tells us what the user trains it for. Multiple schedules with different + /// goals tie-break by the user's goal order. Nil = truly unassigned. + private func inferredGoal(for workout: Workout) -> GoalKind? { + guard let rid = workout.routineID else { return nil } + let liveID = sync.currentRoutineID(for: rid) + let goals = schedules + .filter { sync.currentRoutineID(for: $0.routineID) == liveID } + .compactMap(\.goalKind) + let order = GoalKind.orderedCases() + return goals.min { + (order.firstIndex(of: $0) ?? .max) < (order.firstIndex(of: $1) ?? .max) + } + } + /// Motivational openers for the board title — each reads as a phrase completed /// by "Today" ("Seize Today"). One per day, rotating by day-of-era: stable all /// day, fresh the next. @@ -115,16 +143,17 @@ struct TodayView: View { ForEach(goalGroups, id: \.kind) { group in Section { ForEach(group.schedules) { scheduleRow($0) } + ForEach(group.orphans) { orphanWorkoutRow($0) } } header: { Label(group.kind.displayName, systemImage: group.kind.systemImage) .foregroundStyle(Color.color(from: group.kind.colorName)) } } - if !unassignedSchedules.isEmpty || !orphanWorkouts.isEmpty { + if !unassignedSchedules.isEmpty || !unassignedOrphanWorkouts.isEmpty { Section("Unassigned") { ForEach(unassignedSchedules) { scheduleRow($0) } - ForEach(orphanWorkouts) { orphanWorkoutRow($0) } + ForEach(unassignedOrphanWorkouts) { orphanWorkoutRow($0) } } } }