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
+3 -11
View File
@@ -24,7 +24,6 @@ struct ScheduleFacts: Sendable, Equatable {
var goal: GoalKind?
var recurrence: ScheduleRecurrence
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var timesPerWeek: Int? // .frequency only
var onceDate: Date? // .once only
var createdAt: Date
var routineID: String // live-resolved
@@ -69,8 +68,8 @@ struct WeekMark: Sendable, Equatable {
struct ScheduleTrack: Sendable, Equatable {
var schedule: ScheduleFacts
var weeks: [WeekMark?]
/// The current week can no longer be met (a daily's missed day, too few days left
/// for the remaining frequency count). Distinct from "not yet hit".
/// The current week can no longer be met (a daily's missed day, or too few days
/// left to cover a fixed day already missed). Distinct from "not yet hit".
var currentWeekBroken: Bool
var currentWeek: WeekMark? { weeks.last ?? nil }
@@ -224,8 +223,6 @@ enum ProgressPlanner {
/// - `.daily` / `.fixedDays`: expected counts only required days **up to today**;
/// met counts distinct completed days in that same window (so a Tuesday make-up
/// for a missed fixed Monday still counts catch-ups are honored).
/// - `.frequency`: expected is the full weekly target (clamped to the days the
/// schedule existed that week); mid-week it reads "1 of 2" until actually met.
/// - `.once`: expected 1, only in the week containing its date.
static func weekMark(
schedule: ScheduleFacts,
@@ -248,9 +245,6 @@ enum ProgressPlanner {
let required = window.elapsedDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
return WeekMark(weekStart: weekStart, met: min(done, required), expected: required)
case .frequency:
let target = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
return WeekMark(weekStart: weekStart, met: min(done, target), expected: target)
case .once:
guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
@@ -261,7 +255,7 @@ enum ProgressPlanner {
/// True when the current week can no longer be met: the days remaining (today
/// included) are fewer than the requirement still outstanding. A daily's missed
/// yesterday breaks immediately; a 2×/week with one done and two days left doesn't.
/// yesterday breaks immediately; fixed days with catch-up days still left don't.
static func currentWeekBroken(
schedule: ScheduleFacts,
doneDays: Set<Date>,
@@ -285,8 +279,6 @@ enum ProgressPlanner {
let wanted = Set(schedule.weekdays ?? [])
fullRequirement = window.fullDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
case .frequency:
fullRequirement = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
case .once:
guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
@@ -95,7 +95,6 @@ struct GoalTrackCard: View {
.font(.subheadline)
Text(schedule.recurrence.summary(
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
date: schedule.onceDate))
.font(.caption)
.foregroundStyle(.secondary)
@@ -70,7 +70,6 @@ struct ProgressTabView: View {
goal: schedule.goalKind,
recurrence: schedule.recurrenceEnum,
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
onceDate: schedule.date,
createdAt: schedule.createdAt,
routineID: sync.currentRoutineID(for: schedule.routineID),
+87 -25
View File
@@ -9,8 +9,45 @@ import IndieSync
import SwiftUI
import SwiftData
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity.
/// The UI-only "When" choice a superset of `ScheduleRecurrence` with one extra
/// option, `.now`, that never gets persisted: it skips the schedule document
/// entirely and hands the routine straight back to the caller to start immediately.
private enum WhenChoice: String, CaseIterable, Identifiable {
case now, once, daily, fixedDays
var id: String { rawValue }
var displayName: String {
switch self {
case .now: "Now"
case .once: "Once"
case .daily: "Daily"
case .fixedDays: "Fixed Days"
}
}
/// The persisted recurrence this choice maps to, or nil for `.now` (never persisted).
var recurrence: ScheduleRecurrence? {
switch self {
case .now: nil
case .once: .once
case .daily: .daily
case .fixedDays: .fixedDays
}
}
init(recurrence: ScheduleRecurrence) {
switch recurrence {
case .once: self = .once
case .daily: self = .daily
case .fixedDays: self = .fixedDays
}
}
}
/// Add / edit a workout plan: pick a routine, tag it with a goal, and choose when it
/// happens right now (unscheduled, adding only), once, daily, or on fixed days.
/// Fields are snapshotted in `init` so the sheet never holds the live entity.
struct ScheduleAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@@ -28,11 +65,19 @@ struct ScheduleAddEditView: View {
/// has since been deleted so the user knows what they're reassigning.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence
@State private var when: WhenChoice
@State private var weekdays: Set<Int>
@State private var timesPerWeek: Int
@State private var date: Date
/// "Now" starts a workout immediately instead of saving a schedule only offered
/// when adding (not editing) for today's default date, since a past/future day
/// can't be started "now".
private let offersNow: Bool
/// Called (after dismiss) when the user picks "Now" and taps Start Workout, with
/// the routine to start. The Today board starts it and navigates in.
private let onStartNow: (Routine) -> Void
/// Calendar weekday numbers in Monday-first display order (MonSat = 27, Sun = 1).
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
/// Gregorian short names, 0-indexed from Sunday weekday `n` is `symbols[n - 1]`.
@@ -40,17 +85,25 @@ struct ScheduleAddEditView: View {
/// `defaultDate` seeds the `.once` day when adding the Today board passes the day
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
init(schedule: Schedule? = nil, defaultDate: Date = Date()) {
init(schedule: Schedule? = nil, defaultDate: Date = Date(),
onStartNow: @escaping (Routine) -> Void = { _ in }) {
_scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind)
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
_date = State(initialValue: schedule?.date ?? defaultDate)
self.onStartNow = onStartNow
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
self.offersNow = offersNow
if let schedule {
_when = State(initialValue: WhenChoice(recurrence: schedule.recurrenceEnum))
} else {
_when = State(initialValue: offersNow ? .now : .once)
}
}
private var isEditing: Bool { scheduleID != nil }
@@ -60,7 +113,7 @@ struct ScheduleAddEditView: View {
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if recurrence == .fixedDays { return !weekdays.isEmpty }
if when == .fixedDays { return !weekdays.isEmpty }
return true
}
@@ -68,17 +121,20 @@ struct ScheduleAddEditView: View {
NavigationStack {
Form {
routineSection
goalSection
recurrenceSection
if when != .now {
goalSection
}
whenSection
}
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
.navigationTitle(isEditing ? "Edit Workout" : "New Workout")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save", action: save).disabled(!canSave)
Button(when == .now ? "Start Workout" : "Save", action: save)
.disabled(!canSave)
}
}
.onAppear {
@@ -124,28 +180,26 @@ struct ScheduleAddEditView: View {
}
@ViewBuilder
private var recurrenceSection: some View {
private var whenSection: some View {
Section {
Picker("Repeat", selection: $recurrence) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
Text(r.displayName).tag(r)
Picker("When", selection: $when) {
ForEach(WhenChoice.allCases) { choice in
if choice != .now || offersNow {
Text(choice.displayName).tag(choice)
}
}
}
switch recurrence {
switch when {
case .now, .daily:
EmptyView()
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
case .daily:
EmptyView()
case .fixedDays:
weekdayPicker
case .frequency:
Stepper(value: $timesPerWeek, in: 1...7) {
Text("\(timesPerWeek)× per week")
}
}
} header: {
Text("Repeat")
Text("When")
}
}
@@ -174,6 +228,15 @@ struct ScheduleAddEditView: View {
private func save() {
guard let routine = selectedRoutine else { return }
// "Now" never persists a schedule dismiss and hand the routine back so the
// Today board starts it immediately (mirroring its own start path).
guard let recurrence = when.recurrence else {
dismiss()
onStartNow(routine)
return
}
let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(),
@@ -182,7 +245,6 @@ struct ScheduleAddEditView: View {
goal: goal?.rawValue,
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
date: recurrence == .once ? date : nil,
order: isEditing ? order : nextOrder(),
createdAt: createdAt,
+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)
}
}
}