File ad hoc workouts under their routine's goal on the Today board

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
This commit is contained in:
2026-07-12 00:37:05 -04:00
parent 7b6c39d8f0
commit c24bc9cfeb
+36 -7
View File
@@ -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) }
}
}
}