From 7cb2d6da2625b49c53750fc167ee6c744bb060ff Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 11 Jul 2026 11:09:11 -0400 Subject: [PATCH] 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) --- CHANGELOG.md | 8 ++ Shared/Model/Documents.swift | 5 +- Shared/Model/Entities.swift | 8 +- Shared/Model/Enums.swift | 13 +- Shared/Model/Mappers.swift | 5 +- Shared/Screenshots/ScreenshotSeed.swift | 10 +- UX-REDESIGN.md | 34 ++---- Workouts/Progress/ProgressPlanner.swift | 14 +-- Workouts/Views/Progress/GoalTrackCard.swift | 1 - Workouts/Views/Progress/ProgressTabView.swift | 1 - .../Views/Today/ScheduleAddEditView.swift | 112 ++++++++++++++---- Workouts/Views/Today/TodayView.swift | 94 +++++++++++++-- WorkoutsTests/ProgressPlannerTests.swift | 53 +-------- WorkoutsTests/ScheduleDocumentTests.swift | 72 ++++------- 14 files changed, 231 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197fef9..5e1ecfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ **July 2026** +The Today board now shows just the day's docket — only schedules actually due on the viewed day appear. + +Adding from the Today board is now New Workout, with a Now option that starts a workout immediately instead of creating a schedule. + +Workouts started ad hoc now show up as their own rows on that day's board. + +The times-per-week scheduling option has been removed; use daily or fixed-day schedules instead. + A new Today board is the app's home tab: schedule routines by day, grouped by goal, and start or review each day's workouts from one place. The Today board can step back and forth by day or jump to any date from a calendar that dots the days with logged workouts. diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 6e6c3f8..77ad4da 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -376,7 +376,6 @@ struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable { var goal: String? // GoalKind raw value; nil = unassigned var recurrence: String // ScheduleRecurrence raw value var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only - var timesPerWeek: Int? // .frequency only var date: Date? = nil // the one-off day; .once only var order: Int var createdAt: Date @@ -394,9 +393,9 @@ extension ScheduleDocument { /// The recurrence, defaulting to `.daily` on an unknown raw value. var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily } - /// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu" / "2× per week"). + /// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu"). var recurrenceSummary: String { - recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date) + recurrenceEnum.summary(weekdays: weekdays, date: date) } } diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index 96e6aa2..87892ca 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -317,7 +317,6 @@ final class Schedule { var goalRaw: String? var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue var weekdays: [Int]? - var timesPerWeek: Int? var date: Date? var order: Int = 0 var createdAt: Date = Date() @@ -325,7 +324,7 @@ final class Schedule { var jsonRelativePath: String = "" init(id: String, routineID: String, routineName: String, goalRaw: String?, - recurrenceRaw: String, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil, + recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) { self.id = id self.routineID = routineID @@ -333,7 +332,6 @@ final class Schedule { self.goalRaw = goalRaw self.recurrenceRaw = recurrenceRaw self.weekdays = weekdays - self.timesPerWeek = timesPerWeek self.date = date self.order = order self.createdAt = createdAt @@ -352,8 +350,8 @@ final class Schedule { } /// Human-readable recurrence line — shares its formatting with `ScheduleDocument` - /// via `ScheduleRecurrence.summary(weekdays:timesPerWeek:date:)`. + /// via `ScheduleRecurrence.summary(weekdays:date:)`. var recurrenceSummary: String { - recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date) + recurrenceEnum.summary(weekdays: weekdays, date: date) } } diff --git a/Shared/Model/Enums.swift b/Shared/Model/Enums.swift index 557beb7..d0a1db7 100644 --- a/Shared/Model/Enums.swift +++ b/Shared/Model/Enums.swift @@ -198,14 +198,13 @@ extension GoalKind { /// How often a schedule expects its routine. Raw values are persisted in ScheduleDocument. enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable { - case once, daily, fixedDays, frequency + case once, daily, fixedDays var displayName: String { switch self { case .once: "Once" case .daily: "Daily" case .fixedDays: "Fixed Days" - case .frequency: "Times per Week" } } } @@ -213,10 +212,10 @@ enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable { extension ScheduleRecurrence { /// Human-readable recurrence description shared by `ScheduleDocument` and the /// `Schedule` cache entity, so both render identically. `weekdays` uses Calendar - /// weekday numbers (1 = Sun … 7 = Sat); `timesPerWeek` is the frequency count; - /// `date` is the one-off day (`.once` only). A degenerate case (fixed days with no - /// weekdays, a nil count or date) falls back to the recurrence's own `displayName`. - func summary(weekdays: [Int]?, timesPerWeek: Int?, date: Date?) -> String { + /// weekday numbers (1 = Sun … 7 = Sat); `date` is the one-off day (`.once` only). + /// A degenerate case (fixed days with no weekdays, a nil date) falls back to the + /// recurrence's own `displayName`. + func summary(weekdays: [Int]?, date: Date?) -> String { switch self { case .once: return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once" @@ -230,8 +229,6 @@ extension ScheduleRecurrence { (1...7).contains(n) ? symbols[n - 1] : nil } return names.isEmpty ? displayName : Self.joined(names) - case .frequency: - return "\(timesPerWeek ?? 0)× per week" } } diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index a9f6a72..5189548 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -91,7 +91,7 @@ extension ScheduleDocument { self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id, routineID: schedule.routineID, routineName: schedule.routineName, goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw, - weekdays: schedule.weekdays, timesPerWeek: schedule.timesPerWeek, + weekdays: schedule.weekdays, date: schedule.date, order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt) } @@ -255,7 +255,7 @@ enum CacheMapper { } else { schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName, goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays, - timesPerWeek: doc.timesPerWeek, date: doc.date, order: doc.order, + date: doc.date, order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt, jsonRelativePath: relativePath) context.insert(schedule) @@ -265,7 +265,6 @@ enum CacheMapper { schedule.goalRaw = doc.goal schedule.recurrenceRaw = doc.recurrence schedule.weekdays = doc.weekdays - schedule.timesPerWeek = doc.timesPerWeek schedule.date = doc.date schedule.order = doc.order schedule.createdAt = doc.createdAt diff --git a/Shared/Screenshots/ScreenshotSeed.swift b/Shared/Screenshots/ScreenshotSeed.swift index 78c1bb8..ca72541 100644 --- a/Shared/Screenshots/ScreenshotSeed.swift +++ b/Shared/Screenshots/ScreenshotSeed.swift @@ -66,10 +66,10 @@ enum ScreenshotSeed { // ---- Schedules (the Today board's rows, spanning two goal groups) ------- let schedulePlans: [(routine: Routine, goal: GoalKind, recurrence: ScheduleRecurrence, - weekdays: [Int]?, timesPerWeek: Int?)] = [ - (created[0], .strength, .fixedDays, [2, 5], nil), // Upper Body · Mon & Thu - (created[1], .strength, .frequency, nil, 2), // Lower Body · 2× per week - (created[2], .mobility, .daily, nil, nil), // Core · daily + weekdays: [Int]?)] = [ + (created[0], .strength, .fixedDays, [2, 5]), // Upper Body · Mon & Thu + (created[1], .strength, .fixedDays, [3, 7]), // Lower Body · Tue & Sat + (created[2], .mobility, .daily, nil), // Core · daily ] // Schedules predate the history below, so the Progress tab's adherence // tracks cover the seeded weeks instead of starting "today". @@ -78,7 +78,7 @@ enum ScreenshotSeed { context.insert(Schedule( id: ULID.make(), routineID: plan.routine.id, routineName: plan.routine.name, goalRaw: plan.goal.rawValue, recurrenceRaw: plan.recurrence.rawValue, - weekdays: plan.weekdays, timesPerWeek: plan.timesPerWeek, order: i, + weekdays: plan.weekdays, order: i, createdAt: scheduledSince, updatedAt: scheduledSince, jsonRelativePath: "")) } diff --git a/UX-REDESIGN.md b/UX-REDESIGN.md index d4cfe53..d9ccf38 100644 --- a/UX-REDESIGN.md +++ b/UX-REDESIGN.md @@ -52,18 +52,17 @@ A schedule = **one routine + a recurrence + an optional goal tag**, persisted as `Schedules/.json`. Recurrence flavors: 1. **Fixed days** — "every Mon & Thu". Deterministic; the board just shows it. -2. **Frequency** — "2×/week, whenever". The planner picks the days. -3. **Daily** — e.g. the morning routine or evening meditation. +2. **Daily** — e.g. the morning routine or evening meditation. + +(A one-off `.once` recurrence also exists for a single-day plan; a "times per +week, whenever" flexible mode was tried and removed — it added planner +complexity nobody used, and fixed days cover the common case just as well.) Plus an optional **time window** (e.g. morning, default cutoff ~11 am, tunable) with a per-schedule setting for whether the window gates the streak: on = only in-window starts feed the streak, though the row stays startable and completable all day; off = any-time completion counts. -**Pool rotation is emergent, not a feature**: three 1×/week frequency -schedules (Legs, Upper, Core) under Strength rotate naturally via the -planner's recency pressure. Schedules stay minimal. - **Missed fixed days get a catch-up nudge**: the schedule reappears next day as due-with-a-reason ("missed Mon · catch up?"), planner-placed on the best make-up day before the next fixed slot (recovery-aware in Phase 2), with an @@ -105,19 +104,15 @@ Thursday, July 10 │ ✓ Mobility ·7:04· [Morning] 🔥12│ done — stays in place, streak ticked │ ● Lower Body [Strength] │ fixed schedule, due today │ Mon & Thu · ▶ Start │ -│ · Zone-2 Ride [Endurance] │ flexible — ambient status row -│ 1 of 2 this week · 3 days left │ └──────────────────────────────────┘ This week ▪▪▪▫▫▫▫ + one encouraging line ``` -- **Row states**: *due* (start button, streak at stake — fixed schedules on - their day, flexible ones when the planner says so, catch-ups after a miss) → - *in progress* (resume affordance, always wins attention) → *done* (✓, time, - bumped streak). Done rows stay in place — the agenda's shape is stable and - the day visibly fills up. Flexible schedules not due today render as - **ambient** compact rows (week progress, on-pace, next suggested day), - tappable to start anyway. Never blocked, just not shouting. +- **Row states**: *due* (start button, streak at stake — fixed/daily + schedules on their day, catch-ups after a miss) → *in progress* (resume + affordance, always wins attention) → *done* (✓, time, bumped streak). Done + rows stay in place — the agenda's shape is stable and the day visibly fills + up. - **Due rows carry reason chips** from the planner ("missed Mon", "5 days since legs · you're usually here Thursdays") — never a black box. An override is a non-event and becomes signal. @@ -135,12 +130,8 @@ This week ▪▪▪▫▫▫▫ + one encouraging line A **pure, unit-testable scorer** (precedent: `SeedReconcilePlanner`). With explicit schedules, its scope narrows to where inference is genuinely needed: -- **Placing flexible (frequency) schedules** — which of the remaining days - this week, driven by recency pressure vs. days left. - **Catch-up placement** — the best make-up day for a missed fixed slot before its next occurrence. -- **Ordering emergent rotations** — several flexible schedules under one goal - rotate via recency pressure. - **(Phase 2) Recovery guard** — overlap between a routine's target muscles and muscles trained in the last ~48 h downweights it, using the bundled library's `Targets:` metadata (already parsed by `ExerciseInfo`). Because it @@ -149,10 +140,7 @@ explicit schedules, its scope narrows to where inference is genuinely needed: - **(Phase 2) Location affinity** — boost routines historically done at the current place; missing/denied location contributes nothing. -Weekday affinity remains a minor learned signal for flexible schedules. -**Cold start:** with little history, flexible placement falls back to even -spacing and says so. Deterministic, hand-tuned weights, fixture-history unit -tests. +Deterministic, hand-tuned weights, fixture-history unit tests. ### Location capture (Phase 2) diff --git a/Workouts/Progress/ProgressPlanner.swift b/Workouts/Progress/ProgressPlanner.swift index b3008d4..dc026d6 100644 --- a/Workouts/Progress/ProgressPlanner.swift +++ b/Workouts/Progress/ProgressPlanner.swift @@ -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, @@ -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 diff --git a/Workouts/Views/Progress/GoalTrackCard.swift b/Workouts/Views/Progress/GoalTrackCard.swift index 7c56e14..2132add 100644 --- a/Workouts/Views/Progress/GoalTrackCard.swift +++ b/Workouts/Views/Progress/GoalTrackCard.swift @@ -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) diff --git a/Workouts/Views/Progress/ProgressTabView.swift b/Workouts/Views/Progress/ProgressTabView.swift index 2e921c0..ab0cf43 100644 --- a/Workouts/Views/Progress/ProgressTabView.swift +++ b/Workouts/Views/Progress/ProgressTabView.swift @@ -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), diff --git a/Workouts/Views/Today/ScheduleAddEditView.swift b/Workouts/Views/Today/ScheduleAddEditView.swift index 83a34df..432a4a4 100644 --- a/Workouts/Views/Today/ScheduleAddEditView.swift +++ b/Workouts/Views/Today/ScheduleAddEditView.swift @@ -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 - @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 (Mon…Sat = 2…7, 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, diff --git a/Workouts/Views/Today/TodayView.swift b/Workouts/Views/Today/TodayView.swift index e8d0fde..7ac2b03 100644 --- a/Workouts/Views/Today/TodayView.swift +++ b/Workouts/Views/Today/TodayView.swift @@ -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) + } + } } diff --git a/WorkoutsTests/ProgressPlannerTests.swift b/WorkoutsTests/ProgressPlannerTests.swift index 211e2e5..e7fb0bf 100644 --- a/WorkoutsTests/ProgressPlannerTests.swift +++ b/WorkoutsTests/ProgressPlannerTests.swift @@ -43,14 +43,13 @@ struct ProgressPlannerTests { goal: GoalKind? = .strength, recurrence: ScheduleRecurrence, weekdays: [Int]? = nil, - timesPerWeek: Int? = nil, onceDate: Date? = nil, createdAt: Date, routineID: String = "R-1", routineName: String = "Push Day" ) -> ScheduleFacts { ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays, - timesPerWeek: timesPerWeek, onceDate: onceDate, createdAt: createdAt, + onceDate: onceDate, createdAt: createdAt, routineID: routineID, routineName: routineName) } @@ -149,37 +148,6 @@ struct ProgressPlannerTests { #expect(current?.met == 2) } - // MARK: - weekMark: frequency - - @Test func weekMarkFrequency() { - let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12 - let currentWeek = day("2026-07-06") - - // Expected is the full weekly target even mid-week — NOT clamped down to the - // 3 days that have elapsed so far. - let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01")) - let untouched = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: [], - weekStart: currentWeek, calendar: cal, now: now) - #expect(untouched?.expected == 2) - #expect(untouched?.met == 0) - - // Met is capped at the target even when more sessions were logged. - let threeDone: Set = [day("2026-07-06"), day("2026-07-07"), day("2026-07-08")] - let overMet = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: threeDone, - weekStart: currentWeek, calendar: cal, now: now) - #expect(overMet?.met == 2) - #expect(overMet?.expected == 2) - #expect(overMet?.hit == true) - - // Target clamps to the days the schedule existed in its creation week: created - // Wednesday leaves only 5 days (Wed–Sun) in that week, so a 7x/week target - // clamps down to 5, not 7. - let createdMidWeek = schedule(recurrence: .frequency, timesPerWeek: 7, createdAt: day("2026-07-08")) - let clamped = ProgressPlanner.weekMark(schedule: createdMidWeek, doneDays: [], - weekStart: currentWeek, calendar: cal, now: day("2026-07-12")) - #expect(clamped?.expected == 5) - } - // MARK: - weekMark: once @Test func weekMarkOnce() { @@ -247,25 +215,6 @@ struct ProgressPlannerTests { weekStart: weekStart, calendar: cal, now: now)) } - @Test func currentWeekBrokenFrequencyDaysRemainingVsRequirement() { - let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01")) - let weekStart = day("2026-07-06") - let oneDone: Set = [day("2026-07-07")] - - // Saturday: 2 days left (Sat, Sun). 1 done, 1 still needed — achievable. - #expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone, - weekStart: weekStart, calendar: cal, now: day("2026-07-11"))) - - // Sunday: only 1 day left (today itself). 1 done, 1 still needed — needs - // exactly 1 ≤ 1 day left, so still achievable. - #expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone, - weekStart: weekStart, calendar: cal, now: day("2026-07-12"))) - - // Sunday again, but nothing done yet: 2 still needed with only 1 day left — broken. - #expect(ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: [], - weekStart: weekStart, calendar: cal, now: day("2026-07-12"))) - } - @Test func currentWeekBrokenFixedDaysCatchUpDaysRemainingNotBroken() { // Mon/Wed/Fri required. Monday (a fixed day) was missed, but it's only // Tuesday — catch-up days (Wed, Fri, or any other day this week) still remain. diff --git a/WorkoutsTests/ScheduleDocumentTests.swift b/WorkoutsTests/ScheduleDocumentTests.swift index ec47152..9b0a7d7 100644 --- a/WorkoutsTests/ScheduleDocumentTests.swift +++ b/WorkoutsTests/ScheduleDocumentTests.swift @@ -31,7 +31,6 @@ struct ScheduleDocumentTests { goal: GoalKind.strength.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue, weekdays: [2, 5], - timesPerWeek: nil, order: 3, createdAt: Self.created, updatedAt: Self.updated @@ -46,11 +45,10 @@ struct ScheduleDocumentTests { #expect(decoded.isReadable) #expect(decoded.goalKind == .strength) #expect(decoded.recurrenceEnum == .fixedDays) - #expect(decoded.timesPerWeek == nil) } - /// The nil-heavy cases round-trip too: a `.daily` schedule with no goal, no weekdays, - /// and no frequency count. Absent optionals decode back to nil (not phantom values). + /// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no + /// weekdays. Absent optionals decode back to nil (not phantom values). @Test func encodeDecodeRoundTripDailyWithNils() throws { let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, @@ -60,7 +58,6 @@ struct ScheduleDocumentTests { goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, weekdays: nil, - timesPerWeek: nil, order: 0, createdAt: Self.created, updatedAt: Self.updated @@ -72,7 +69,6 @@ struct ScheduleDocumentTests { // quoted key — "updatedAt" contains it as a substring.) #expect(!json.contains("goal")) #expect(!json.contains("weekdays")) - #expect(!json.contains("timesPerWeek")) #expect(!json.contains("\"date\"")) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data) @@ -80,7 +76,6 @@ struct ScheduleDocumentTests { #expect(decoded.goal == nil) #expect(decoded.goalKind == nil) #expect(decoded.weekdays == nil) - #expect(decoded.timesPerWeek == nil) #expect(decoded.date == nil) } @@ -91,7 +86,7 @@ struct ScheduleDocumentTests { id: ULID.make(), routineID: "R", routineName: "Trail Run", goal: GoalKind.cardio.rawValue, recurrence: ScheduleRecurrence.once.rawValue, - weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 2, + weekdays: nil, date: Self.onceDay, order: 2, createdAt: Self.created, updatedAt: Self.updated ) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original)) @@ -99,23 +94,6 @@ struct ScheduleDocumentTests { #expect(decoded.recurrenceEnum == .once) #expect(decoded.date == Self.onceDay) #expect(decoded.weekdays == nil) - #expect(decoded.timesPerWeek == nil) - } - - /// A `.frequency` schedule carries its count and drops weekdays. - @Test func encodeDecodeRoundTripFrequency() throws { - let original = ScheduleDocument( - schemaVersion: ScheduleDocument.currentSchemaVersion, - id: ULID.make(), routineID: "R", routineName: "Cardio", - goal: GoalKind.cardio.rawValue, - recurrence: ScheduleRecurrence.frequency.rawValue, - weekdays: nil, timesPerWeek: 3, order: 1, - createdAt: Self.created, updatedAt: Self.updated - ) - let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original)) - #expect(decoded == original) - #expect(decoded.recurrenceEnum == .frequency) - #expect(decoded.timesPerWeek == 3) } // MARK: - Forward-compatibility gate @@ -127,7 +105,7 @@ struct ScheduleDocumentTests { func doc(schema: Int) -> ScheduleDocument { ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N", goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, - weekdays: nil, timesPerWeek: nil, order: 0, + weekdays: nil, order: 0, createdAt: Self.created, updatedAt: Self.updated) } #expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable) @@ -145,38 +123,34 @@ struct ScheduleDocumentTests { // Once — the one-off day, medium-date formatted (routing check; the format // itself is `Date.formatDate()`'s). Degenerate nil date → the display name. - #expect(summary(.once, weekdays: nil, timesPerWeek: nil, date: Self.onceDay) == Self.onceDay.formatDate()) - #expect(summary(.once, weekdays: nil, timesPerWeek: nil) == "Once") + #expect(summary(.once, weekdays: nil, date: Self.onceDay) == Self.onceDay.formatDate()) + #expect(summary(.once, weekdays: nil) == "Once") // Daily. - #expect(summary(.daily, weekdays: nil, timesPerWeek: nil) == "Daily") + #expect(summary(.daily, weekdays: nil) == "Daily") // Fixed days — sorted, joined with "&"; weekday 2 = Mon, 5 = Thu. - #expect(summary(.fixedDays, weekdays: [5, 2], timesPerWeek: nil) == "\(sym[1]) & \(sym[4])") + #expect(summary(.fixedDays, weekdays: [5, 2]) == "\(sym[1]) & \(sym[4])") // Single day → just the name. - #expect(summary(.fixedDays, weekdays: [2], timesPerWeek: nil) == sym[1]) + #expect(summary(.fixedDays, weekdays: [2]) == sym[1]) // Three days → Oxford-style "A, B & C". - #expect(summary(.fixedDays, weekdays: [2, 4, 6], timesPerWeek: nil) == "\(sym[1]), \(sym[3]) & \(sym[5])") + #expect(summary(.fixedDays, weekdays: [2, 4, 6]) == "\(sym[1]), \(sym[3]) & \(sym[5])") // Degenerate (no weekdays) → falls back to the recurrence display name. - #expect(summary(.fixedDays, weekdays: [], timesPerWeek: nil) == "Fixed Days") - #expect(summary(.fixedDays, weekdays: nil, timesPerWeek: nil) == "Fixed Days") + #expect(summary(.fixedDays, weekdays: []) == "Fixed Days") + #expect(summary(.fixedDays, weekdays: nil) == "Fixed Days") // Out-of-range weekday numbers are ignored. - #expect(summary(.fixedDays, weekdays: [0, 2, 9], timesPerWeek: nil) == sym[1]) - - // Frequency. - #expect(summary(.frequency, weekdays: nil, timesPerWeek: 2) == "2× per week") - #expect(summary(.frequency, weekdays: nil, timesPerWeek: nil) == "0× per week") + #expect(summary(.fixedDays, weekdays: [0, 2, 9]) == sym[1]) } - private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil) -> String { + private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, date: Date? = nil) -> String { let doc = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N", - goal: nil, recurrence: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek, + goal: nil, recurrence: r.rawValue, weekdays: weekdays, date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated ) // The entity must format identically to the document — this is the shared-logic check. let entity = Schedule(id: "X", routineID: "R", routineName: "N", goalRaw: nil, - recurrenceRaw: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek, + recurrenceRaw: r.rawValue, weekdays: weekdays, date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated, jsonRelativePath: "") #expect(doc.recurrenceSummary == entity.recurrenceSummary) @@ -205,7 +179,6 @@ struct ScheduleDocumentTests { goal: GoalKind.mobility.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue, weekdays: [2, 4, 6], - timesPerWeek: nil, order: 7, createdAt: Self.created, updatedAt: Self.updated @@ -222,7 +195,6 @@ struct ScheduleDocumentTests { #expect(entity.recurrenceRaw == original.recurrence) #expect(entity.recurrenceEnum == .fixedDays) #expect(entity.weekdays == [2, 4, 6]) - #expect(entity.timesPerWeek == nil) #expect(entity.order == 7) #expect(entity.jsonRelativePath == "Schedules/\(original.id).json") @@ -240,7 +212,7 @@ struct ScheduleDocumentTests { schemaVersion: ScheduleDocument.currentSchemaVersion, id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run", goal: nil, recurrence: ScheduleRecurrence.once.rawValue, - weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 0, + weekdays: nil, date: Self.onceDay, order: 0, createdAt: Self.created, updatedAt: Self.updated ) CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context) @@ -267,7 +239,7 @@ struct ScheduleDocumentTests { schemaVersion: ScheduleDocument.currentSchemaVersion, id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name", goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, - weekdays: nil, timesPerWeek: nil, order: 0, + weekdays: nil, order: 0, createdAt: Self.created, updatedAt: Self.created ) CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context) @@ -276,8 +248,8 @@ struct ScheduleDocumentTests { var second = first second.routineName = "New Name" second.goal = GoalKind.strength.rawValue - second.recurrence = ScheduleRecurrence.frequency.rawValue - second.timesPerWeek = 4 + second.recurrence = ScheduleRecurrence.fixedDays.rawValue + second.weekdays = [2, 4] second.updatedAt = Self.updated CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context) try context.save() @@ -287,8 +259,8 @@ struct ScheduleDocumentTests { let entity = try #require(all.first) #expect(entity.routineName == "New Name") #expect(entity.goalKind == .strength) - #expect(entity.recurrenceEnum == .frequency) - #expect(entity.timesPerWeek == 4) + #expect(entity.recurrenceEnum == .fixedDays) + #expect(entity.weekdays == [2, 4]) #expect(ScheduleDocument(from: entity) == second) } }