Show only the day's docket on the Today board and add ad hoc Now workouts

- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
This commit is contained in:
2026-07-11 11:09:11 -04:00
parent 9dc9283b4c
commit 7cb2d6da26
14 changed files with 231 additions and 199 deletions
+82 -12
View File
@@ -41,14 +41,35 @@ struct TodayView: View {
/// The viewed day is strictly before today (day-precision).
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
/// Schedules that belong on the viewed day's board: recurring ones always show
/// (the board displays their recurrence, not a due-filter yet); a one-off shows
/// Schedules due on the viewed day: `.daily` always qualifies; `.fixedDays` only
/// when the viewed day's calendar weekday is one of the schedule's days; `.once`
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
/// rather than nowhere.
private var visibleSchedules: [Schedule] {
schedules.filter { schedule in
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true }
return date.isSameDay(as: selectedDate)
switch schedule.recurrenceEnum {
case .daily:
return true
case .fixedDays:
let weekday = Calendar.current.component(.weekday, from: selectedDate)
return (schedule.weekdays ?? []).contains(weekday)
case .once:
guard let date = schedule.date else { return true }
return date.isSameDay(as: selectedDate)
}
}
}
/// 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.
private var orphanWorkouts: [Workout] {
let scheduledRoutineIDs = Set(visibleSchedules.map { sync.currentRoutineID(for: $0.routineID) })
return workouts.filter { workout in
guard workout.start.isSameDay(as: selectedDate) else { return false }
guard let routineID = workout.routineID else { return true }
return !scheduledRoutineIDs.contains(sync.currentRoutineID(for: routineID))
}
}
@@ -100,24 +121,25 @@ struct TodayView: View {
}
}
if !unassignedSchedules.isEmpty {
if !unassignedSchedules.isEmpty || !orphanWorkouts.isEmpty {
Section("Unassigned") {
ForEach(unassignedSchedules) { scheduleRow($0) }
ForEach(orphanWorkouts) { orphanWorkoutRow($0) }
}
}
}
.overlay {
if schedules.isEmpty {
if schedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView(
"No Schedules Yet",
systemImage: "calendar.badge.plus",
description: Text("Plan your week by adding a schedule for a routine.")
)
} else if visibleSchedules.isEmpty {
} else if visibleSchedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView(
"Nothing Planned",
systemImage: "calendar",
description: Text("Nothing is scheduled for this day. Tap + to plan something.")
description: Text("Looks like a rest day. Tap + to plan or start a workout.")
)
}
}
@@ -150,7 +172,7 @@ struct TodayView: View {
Button {
showingAddSchedule = true
} label: {
Label("Add Schedule", systemImage: "plus")
Label("Add Workout", systemImage: "plus")
}
}
}
@@ -167,8 +189,9 @@ struct TodayView: View {
}
.sheet(isPresented: $showingAddSchedule) {
// Seed a would-be one-off with the day on screen, so navigating to a
// day and tapping + plans that day.
ScheduleAddEditView(defaultDate: selectedDate)
// day and tapping + plans that day. "Now" (offered only on today) hands
// the routine back here to start immediately.
ScheduleAddEditView(defaultDate: selectedDate, onStartNow: startNow)
}
.sheet(item: $scheduleToEdit) { schedule in
ScheduleAddEditView(schedule: schedule)
@@ -225,6 +248,38 @@ struct TodayView: View {
}
}
/// A workout on the viewed day with no matching schedule row an ad hoc "Start
/// Now" run, or a routine whose schedule isn't due today. No recurrence caption
/// (there's no schedule behind it), just the routine name and its status.
private func orphanWorkoutRow(_ workout: Workout) -> some View {
let routine = resolvedRoutine(id: workout.routineID)
return Button {
guard !workout.isDeleted else { return }
pendingWorkoutID = workout.id
} label: {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(routine.map { Color.color(from: $0.color) } ?? .secondary)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? workout.routineName ?? Routine.unnamed)
.foregroundStyle(.primary)
Text("Ad Hoc")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
todayStatus(for: workout)
}
.contentShape(Rectangle())
}
.tint(.primary)
}
/// Today's status decoration: a completed run shows a green check + its finish
/// time; an unfinished one shows an orange "In Progress" tag.
@ViewBuilder
@@ -272,7 +327,14 @@ struct TodayView: View {
/// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted.
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
let liveID = sync.currentRoutineID(for: schedule.routineID)
resolvedRoutine(id: schedule.routineID)
}
/// The live routine for a routine id, following the seed clone-on-edit redirect;
/// nil when there's no id or the routine has been deleted.
private func resolvedRoutine(id: String?) -> Routine? {
guard let id else { return nil }
let liveID = sync.currentRoutineID(for: id)
return routines.first { $0.id == liveID }
}
@@ -325,4 +387,12 @@ struct TodayView: View {
}
}
}
/// The "Now" path from the add sheet: start the chosen routine immediately and
/// navigate in, same as tapping a due schedule row on today's board.
private func startNow(routine: Routine) {
Task {
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
}
}
}