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
+8
View File
@@ -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.
+2 -3
View File
@@ -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)
}
}
+3 -5
View File
@@ -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)
}
}
+5 -8
View File
@@ -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"
}
}
+2 -3
View File
@@ -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
+5 -5
View File
@@ -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: ""))
}
+11 -23
View File
@@ -52,18 +52,17 @@ A schedule = **one routine + a recurrence + an optional goal tag**, persisted
as `Schedules/<ULID>.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)
+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)
}
}
}
+1 -52
View File
@@ -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<Date> = [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 (WedSun) 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<Date> = [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.
+22 -50
View File
@@ -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)
}
}