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** **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. 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. 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 goal: String? // GoalKind raw value; nil = unassigned
var recurrence: String // ScheduleRecurrence raw value var recurrence: String // ScheduleRecurrence raw value
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only 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 date: Date? = nil // the one-off day; .once only
var order: Int var order: Int
var createdAt: Date var createdAt: Date
@@ -394,9 +393,9 @@ extension ScheduleDocument {
/// The recurrence, defaulting to `.daily` on an unknown raw value. /// The recurrence, defaulting to `.daily` on an unknown raw value.
var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily } 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 { 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 goalRaw: String?
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
var weekdays: [Int]? var weekdays: [Int]?
var timesPerWeek: Int?
var date: Date? var date: Date?
var order: Int = 0 var order: Int = 0
var createdAt: Date = Date() var createdAt: Date = Date()
@@ -325,7 +324,7 @@ final class Schedule {
var jsonRelativePath: String = "" var jsonRelativePath: String = ""
init(id: String, routineID: String, routineName: String, goalRaw: 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) { order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id self.id = id
self.routineID = routineID self.routineID = routineID
@@ -333,7 +332,6 @@ final class Schedule {
self.goalRaw = goalRaw self.goalRaw = goalRaw
self.recurrenceRaw = recurrenceRaw self.recurrenceRaw = recurrenceRaw
self.weekdays = weekdays self.weekdays = weekdays
self.timesPerWeek = timesPerWeek
self.date = date self.date = date
self.order = order self.order = order
self.createdAt = createdAt self.createdAt = createdAt
@@ -352,8 +350,8 @@ final class Schedule {
} }
/// Human-readable recurrence line shares its formatting with `ScheduleDocument` /// Human-readable recurrence line shares its formatting with `ScheduleDocument`
/// via `ScheduleRecurrence.summary(weekdays:timesPerWeek:date:)`. /// via `ScheduleRecurrence.summary(weekdays:date:)`.
var recurrenceSummary: String { 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. /// How often a schedule expects its routine. Raw values are persisted in ScheduleDocument.
enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable { enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable {
case once, daily, fixedDays, frequency case once, daily, fixedDays
var displayName: String { var displayName: String {
switch self { switch self {
case .once: "Once" case .once: "Once"
case .daily: "Daily" case .daily: "Daily"
case .fixedDays: "Fixed Days" case .fixedDays: "Fixed Days"
case .frequency: "Times per Week"
} }
} }
} }
@@ -213,10 +212,10 @@ enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable {
extension ScheduleRecurrence { extension ScheduleRecurrence {
/// Human-readable recurrence description shared by `ScheduleDocument` and the /// Human-readable recurrence description shared by `ScheduleDocument` and the
/// `Schedule` cache entity, so both render identically. `weekdays` uses Calendar /// `Schedule` cache entity, so both render identically. `weekdays` uses Calendar
/// weekday numbers (1 = Sun 7 = Sat); `timesPerWeek` is the frequency count; /// weekday numbers (1 = Sun 7 = Sat); `date` is the one-off day (`.once` only).
/// `date` is the one-off day (`.once` only). A degenerate case (fixed days with no /// A degenerate case (fixed days with no weekdays, a nil date) falls back to the
/// weekdays, a nil count or date) falls back to the recurrence's own `displayName`. /// recurrence's own `displayName`.
func summary(weekdays: [Int]?, timesPerWeek: Int?, date: Date?) -> String { func summary(weekdays: [Int]?, date: Date?) -> String {
switch self { switch self {
case .once: case .once:
return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once" return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once"
@@ -230,8 +229,6 @@ extension ScheduleRecurrence {
(1...7).contains(n) ? symbols[n - 1] : nil (1...7).contains(n) ? symbols[n - 1] : nil
} }
return names.isEmpty ? displayName : Self.joined(names) 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, self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id,
routineID: schedule.routineID, routineName: schedule.routineName, routineID: schedule.routineID, routineName: schedule.routineName,
goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw, goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
weekdays: schedule.weekdays, timesPerWeek: schedule.timesPerWeek, weekdays: schedule.weekdays,
date: schedule.date, date: schedule.date,
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt) order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
} }
@@ -255,7 +255,7 @@ enum CacheMapper {
} else { } else {
schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName, schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays, 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, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath) jsonRelativePath: relativePath)
context.insert(schedule) context.insert(schedule)
@@ -265,7 +265,6 @@ enum CacheMapper {
schedule.goalRaw = doc.goal schedule.goalRaw = doc.goal
schedule.recurrenceRaw = doc.recurrence schedule.recurrenceRaw = doc.recurrence
schedule.weekdays = doc.weekdays schedule.weekdays = doc.weekdays
schedule.timesPerWeek = doc.timesPerWeek
schedule.date = doc.date schedule.date = doc.date
schedule.order = doc.order schedule.order = doc.order
schedule.createdAt = doc.createdAt schedule.createdAt = doc.createdAt
+5 -5
View File
@@ -66,10 +66,10 @@ enum ScreenshotSeed {
// ---- Schedules (the Today board's rows, spanning two goal groups) ------- // ---- Schedules (the Today board's rows, spanning two goal groups) -------
let schedulePlans: [(routine: Routine, goal: GoalKind, recurrence: ScheduleRecurrence, let schedulePlans: [(routine: Routine, goal: GoalKind, recurrence: ScheduleRecurrence,
weekdays: [Int]?, timesPerWeek: Int?)] = [ weekdays: [Int]?)] = [
(created[0], .strength, .fixedDays, [2, 5], nil), // Upper Body · Mon & Thu (created[0], .strength, .fixedDays, [2, 5]), // Upper Body · Mon & Thu
(created[1], .strength, .frequency, nil, 2), // Lower Body · 2× per week (created[1], .strength, .fixedDays, [3, 7]), // Lower Body · Tue & Sat
(created[2], .mobility, .daily, nil, nil), // Core · daily (created[2], .mobility, .daily, nil), // Core · daily
] ]
// Schedules predate the history below, so the Progress tab's adherence // Schedules predate the history below, so the Progress tab's adherence
// tracks cover the seeded weeks instead of starting "today". // tracks cover the seeded weeks instead of starting "today".
@@ -78,7 +78,7 @@ enum ScreenshotSeed {
context.insert(Schedule( context.insert(Schedule(
id: ULID.make(), routineID: plan.routine.id, routineName: plan.routine.name, id: ULID.make(), routineID: plan.routine.id, routineName: plan.routine.name,
goalRaw: plan.goal.rawValue, recurrenceRaw: plan.recurrence.rawValue, 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: "")) 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: as `Schedules/<ULID>.json`. Recurrence flavors:
1. **Fixed days** — "every Mon & Thu". Deterministic; the board just shows it. 1. **Fixed days** — "every Mon & Thu". Deterministic; the board just shows it.
2. **Frequency**"2×/week, whenever". The planner picks the days. 2. **Daily**e.g. the morning routine or evening meditation.
3. **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, 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: 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 on = only in-window starts feed the streak, though the row stays startable and
completable all day; off = any-time completion counts. 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 **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 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 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 │ ✓ Mobility ·7:04· [Morning] 🔥12│ done — stays in place, streak ticked
│ ● Lower Body [Strength] │ fixed schedule, due today │ ● Lower Body [Strength] │ fixed schedule, due today
│ Mon & Thu · ▶ Start │ │ Mon & Thu · ▶ Start │
│ · Zone-2 Ride [Endurance] │ flexible — ambient status row
│ 1 of 2 this week · 3 days left │
└──────────────────────────────────┘ └──────────────────────────────────┘
This week ▪▪▪▫▫▫▫ + one encouraging line This week ▪▪▪▫▫▫▫ + one encouraging line
``` ```
- **Row states**: *due* (start button, streak at stake — fixed schedules on - **Row states**: *due* (start button, streak at stake — fixed/daily
their day, flexible ones when the planner says so, catch-ups after a miss) → schedules on their day, catch-ups after a miss) → *in progress* (resume
*in progress* (resume affordance, always wins attention) → *done* (✓, time, affordance, always wins attention) → *done* (✓, time, bumped streak). Done
bumped streak). Done rows stay in place — the agenda's shape is stable and rows stay in place — the agenda's shape is stable and the day visibly fills
the day visibly fills up. Flexible schedules not due today render as up.
**ambient** compact rows (week progress, on-pace, next suggested day),
tappable to start anyway. Never blocked, just not shouting.
- **Due rows carry reason chips** from the planner ("missed Mon", "5 days - **Due rows carry reason chips** from the planner ("missed Mon", "5 days
since legs · you're usually here Thursdays") — never a black box. An since legs · you're usually here Thursdays") — never a black box. An
override is a non-event and becomes signal. 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 A **pure, unit-testable scorer** (precedent: `SeedReconcilePlanner`). With
explicit schedules, its scope narrows to where inference is genuinely needed: 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 - **Catch-up placement** — the best make-up day for a missed fixed slot
before its next occurrence. 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 - **(Phase 2) Recovery guard** — overlap between a routine's target muscles
and muscles trained in the last ~48 h downweights it, using the bundled and muscles trained in the last ~48 h downweights it, using the bundled
library's `Targets:` metadata (already parsed by `ExerciseInfo`). Because it 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 - **(Phase 2) Location affinity** — boost routines historically done at the
current place; missing/denied location contributes nothing. current place; missing/denied location contributes nothing.
Weekday affinity remains a minor learned signal for flexible schedules. Deterministic, hand-tuned weights, fixture-history unit tests.
**Cold start:** with little history, flexible placement falls back to even
spacing and says so. Deterministic, hand-tuned weights, fixture-history unit
tests.
### Location capture (Phase 2) ### Location capture (Phase 2)
+3 -11
View File
@@ -24,7 +24,6 @@ struct ScheduleFacts: Sendable, Equatable {
var goal: GoalKind? var goal: GoalKind?
var recurrence: ScheduleRecurrence var recurrence: ScheduleRecurrence
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var timesPerWeek: Int? // .frequency only
var onceDate: Date? // .once only var onceDate: Date? // .once only
var createdAt: Date var createdAt: Date
var routineID: String // live-resolved var routineID: String // live-resolved
@@ -69,8 +68,8 @@ struct WeekMark: Sendable, Equatable {
struct ScheduleTrack: Sendable, Equatable { struct ScheduleTrack: Sendable, Equatable {
var schedule: ScheduleFacts var schedule: ScheduleFacts
var weeks: [WeekMark?] var weeks: [WeekMark?]
/// The current week can no longer be met (a daily's missed day, too few days left /// The current week can no longer be met (a daily's missed day, or too few days
/// for the remaining frequency count). Distinct from "not yet hit". /// left to cover a fixed day already missed). Distinct from "not yet hit".
var currentWeekBroken: Bool var currentWeekBroken: Bool
var currentWeek: WeekMark? { weeks.last ?? nil } var currentWeek: WeekMark? { weeks.last ?? nil }
@@ -224,8 +223,6 @@ enum ProgressPlanner {
/// - `.daily` / `.fixedDays`: expected counts only required days **up to today**; /// - `.daily` / `.fixedDays`: expected counts only required days **up to today**;
/// met counts distinct completed days in that same window (so a Tuesday make-up /// 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). /// 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. /// - `.once`: expected 1, only in the week containing its date.
static func weekMark( static func weekMark(
schedule: ScheduleFacts, schedule: ScheduleFacts,
@@ -248,9 +245,6 @@ enum ProgressPlanner {
let required = window.elapsedDays let required = window.elapsedDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
return WeekMark(weekStart: weekStart, met: min(done, required), expected: required) 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: case .once:
guard let once = schedule.onceDate, guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart 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 /// 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 /// 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( static func currentWeekBroken(
schedule: ScheduleFacts, schedule: ScheduleFacts,
doneDays: Set<Date>, doneDays: Set<Date>,
@@ -285,8 +279,6 @@ enum ProgressPlanner {
let wanted = Set(schedule.weekdays ?? []) let wanted = Set(schedule.weekdays ?? [])
fullRequirement = window.fullDays fullRequirement = window.fullDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
case .frequency:
fullRequirement = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
case .once: case .once:
guard let once = schedule.onceDate, guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
@@ -95,7 +95,6 @@ struct GoalTrackCard: View {
.font(.subheadline) .font(.subheadline)
Text(schedule.recurrence.summary( Text(schedule.recurrence.summary(
weekdays: schedule.weekdays, weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
date: schedule.onceDate)) date: schedule.onceDate))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -70,7 +70,6 @@ struct ProgressTabView: View {
goal: schedule.goalKind, goal: schedule.goalKind,
recurrence: schedule.recurrenceEnum, recurrence: schedule.recurrenceEnum,
weekdays: schedule.weekdays, weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
onceDate: schedule.date, onceDate: schedule.date,
createdAt: schedule.createdAt, createdAt: schedule.createdAt,
routineID: sync.currentRoutineID(for: schedule.routineID), routineID: sync.currentRoutineID(for: schedule.routineID),
+87 -25
View File
@@ -9,8 +9,45 @@ import IndieSync
import SwiftUI import SwiftUI
import SwiftData import SwiftData
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it /// The UI-only "When" choice a superset of `ScheduleRecurrence` with one extra
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity. /// 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 { struct ScheduleAddEditView: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@@ -28,11 +65,19 @@ struct ScheduleAddEditView: View {
/// has since been deleted so the user knows what they're reassigning. /// has since been deleted so the user knows what they're reassigning.
@State private var originalRoutineName: String @State private var originalRoutineName: String
@State private var goal: GoalKind? @State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence @State private var when: WhenChoice
@State private var weekdays: Set<Int> @State private var weekdays: Set<Int>
@State private var timesPerWeek: Int
@State private var date: Date @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). /// Calendar weekday numbers in Monday-first display order (MonSat = 27, Sun = 1).
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1] private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
/// Gregorian short names, 0-indexed from Sunday weekday `n` is `symbols[n - 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 /// `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. /// 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) _scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0) _order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date()) _createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "") _routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "") _originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind) _goal = State(initialValue: schedule?.goalKind)
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? [])) _weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
_date = State(initialValue: schedule?.date ?? defaultDate) _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 } private var isEditing: Bool { scheduleID != nil }
@@ -60,7 +113,7 @@ struct ScheduleAddEditView: View {
private var canSave: Bool { private var canSave: Bool {
guard selectedRoutine != nil else { return false } guard selectedRoutine != nil else { return false }
if recurrence == .fixedDays { return !weekdays.isEmpty } if when == .fixedDays { return !weekdays.isEmpty }
return true return true
} }
@@ -68,17 +121,20 @@ struct ScheduleAddEditView: View {
NavigationStack { NavigationStack {
Form { Form {
routineSection routineSection
goalSection if when != .now {
recurrenceSection goalSection
}
whenSection
} }
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule") .navigationTitle(isEditing ? "Edit Workout" : "New Workout")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Save", action: save).disabled(!canSave) Button(when == .now ? "Start Workout" : "Save", action: save)
.disabled(!canSave)
} }
} }
.onAppear { .onAppear {
@@ -124,28 +180,26 @@ struct ScheduleAddEditView: View {
} }
@ViewBuilder @ViewBuilder
private var recurrenceSection: some View { private var whenSection: some View {
Section { Section {
Picker("Repeat", selection: $recurrence) { Picker("When", selection: $when) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in ForEach(WhenChoice.allCases) { choice in
Text(r.displayName).tag(r) if choice != .now || offersNow {
Text(choice.displayName).tag(choice)
}
} }
} }
switch recurrence { switch when {
case .now, .daily:
EmptyView()
case .once: case .once:
DatePicker("Date", selection: $date, displayedComponents: .date) DatePicker("Date", selection: $date, displayedComponents: .date)
case .daily:
EmptyView()
case .fixedDays: case .fixedDays:
weekdayPicker weekdayPicker
case .frequency:
Stepper(value: $timesPerWeek, in: 1...7) {
Text("\(timesPerWeek)× per week")
}
} }
} header: { } header: {
Text("Repeat") Text("When")
} }
} }
@@ -174,6 +228,15 @@ struct ScheduleAddEditView: View {
private func save() { private func save() {
guard let routine = selectedRoutine else { return } 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( let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion, schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(), id: scheduleID ?? ULID.make(),
@@ -182,7 +245,6 @@ struct ScheduleAddEditView: View {
goal: goal?.rawValue, goal: goal?.rawValue,
recurrence: recurrence.rawValue, recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil, weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
date: recurrence == .once ? date : nil, date: recurrence == .once ? date : nil,
order: isEditing ? order : nextOrder(), order: isEditing ? order : nextOrder(),
createdAt: createdAt, createdAt: createdAt,
+82 -12
View File
@@ -41,14 +41,35 @@ struct TodayView: View {
/// The viewed day is strictly before today (day-precision). /// The viewed day is strictly before today (day-precision).
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) } private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
/// Schedules that belong on the viewed day's board: recurring ones always show /// Schedules due on the viewed day: `.daily` always qualifies; `.fixedDays` only
/// (the board displays their recurrence, not a due-filter yet); a one-off shows /// 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 /// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
/// rather than nowhere. /// rather than nowhere.
private var visibleSchedules: [Schedule] { private var visibleSchedules: [Schedule] {
schedules.filter { schedule in schedules.filter { schedule in
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true } switch schedule.recurrenceEnum {
return date.isSameDay(as: selectedDate) 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") { Section("Unassigned") {
ForEach(unassignedSchedules) { scheduleRow($0) } ForEach(unassignedSchedules) { scheduleRow($0) }
ForEach(orphanWorkouts) { orphanWorkoutRow($0) }
} }
} }
} }
.overlay { .overlay {
if schedules.isEmpty { if schedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No Schedules Yet", "No Schedules Yet",
systemImage: "calendar.badge.plus", systemImage: "calendar.badge.plus",
description: Text("Plan your week by adding a schedule for a routine.") description: Text("Plan your week by adding a schedule for a routine.")
) )
} else if visibleSchedules.isEmpty { } else if visibleSchedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"Nothing Planned", "Nothing Planned",
systemImage: "calendar", 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 { Button {
showingAddSchedule = true showingAddSchedule = true
} label: { } label: {
Label("Add Schedule", systemImage: "plus") Label("Add Workout", systemImage: "plus")
} }
} }
} }
@@ -167,8 +189,9 @@ struct TodayView: View {
} }
.sheet(isPresented: $showingAddSchedule) { .sheet(isPresented: $showingAddSchedule) {
// Seed a would-be one-off with the day on screen, so navigating to a // Seed a would-be one-off with the day on screen, so navigating to a
// day and tapping + plans that day. // day and tapping + plans that day. "Now" (offered only on today) hands
ScheduleAddEditView(defaultDate: selectedDate) // the routine back here to start immediately.
ScheduleAddEditView(defaultDate: selectedDate, onStartNow: startNow)
} }
.sheet(item: $scheduleToEdit) { schedule in .sheet(item: $scheduleToEdit) { schedule in
ScheduleAddEditView(schedule: schedule) 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 /// Today's status decoration: a completed run shows a green check + its finish
/// time; an unfinished one shows an orange "In Progress" tag. /// time; an unfinished one shows an orange "In Progress" tag.
@ViewBuilder @ViewBuilder
@@ -272,7 +327,14 @@ struct TodayView: View {
/// The live routine this schedule points at, following the seed clone-on-edit /// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted. /// redirect; nil when the routine has been deleted.
private func resolvedRoutine(for schedule: Schedule) -> Routine? { 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 } 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, goal: GoalKind? = .strength,
recurrence: ScheduleRecurrence, recurrence: ScheduleRecurrence,
weekdays: [Int]? = nil, weekdays: [Int]? = nil,
timesPerWeek: Int? = nil,
onceDate: Date? = nil, onceDate: Date? = nil,
createdAt: Date, createdAt: Date,
routineID: String = "R-1", routineID: String = "R-1",
routineName: String = "Push Day" routineName: String = "Push Day"
) -> ScheduleFacts { ) -> ScheduleFacts {
ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays, ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays,
timesPerWeek: timesPerWeek, onceDate: onceDate, createdAt: createdAt, onceDate: onceDate, createdAt: createdAt,
routineID: routineID, routineName: routineName) routineID: routineID, routineName: routineName)
} }
@@ -149,37 +148,6 @@ struct ProgressPlannerTests {
#expect(current?.met == 2) #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 // MARK: - weekMark: once
@Test func weekMarkOnce() { @Test func weekMarkOnce() {
@@ -247,25 +215,6 @@ struct ProgressPlannerTests {
weekStart: weekStart, calendar: cal, now: now)) 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() { @Test func currentWeekBrokenFixedDaysCatchUpDaysRemainingNotBroken() {
// Mon/Wed/Fri required. Monday (a fixed day) was missed, but it's only // 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. // 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, goal: GoalKind.strength.rawValue,
recurrence: ScheduleRecurrence.fixedDays.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue,
weekdays: [2, 5], weekdays: [2, 5],
timesPerWeek: nil,
order: 3, order: 3,
createdAt: Self.created, createdAt: Self.created,
updatedAt: Self.updated updatedAt: Self.updated
@@ -46,11 +45,10 @@ struct ScheduleDocumentTests {
#expect(decoded.isReadable) #expect(decoded.isReadable)
#expect(decoded.goalKind == .strength) #expect(decoded.goalKind == .strength)
#expect(decoded.recurrenceEnum == .fixedDays) #expect(decoded.recurrenceEnum == .fixedDays)
#expect(decoded.timesPerWeek == nil)
} }
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal, no weekdays, /// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
/// and no frequency count. Absent optionals decode back to nil (not phantom values). /// weekdays. Absent optionals decode back to nil (not phantom values).
@Test func encodeDecodeRoundTripDailyWithNils() throws { @Test func encodeDecodeRoundTripDailyWithNils() throws {
let original = ScheduleDocument( let original = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion, schemaVersion: ScheduleDocument.currentSchemaVersion,
@@ -60,7 +58,6 @@ struct ScheduleDocumentTests {
goal: nil, goal: nil,
recurrence: ScheduleRecurrence.daily.rawValue, recurrence: ScheduleRecurrence.daily.rawValue,
weekdays: nil, weekdays: nil,
timesPerWeek: nil,
order: 0, order: 0,
createdAt: Self.created, createdAt: Self.created,
updatedAt: Self.updated updatedAt: Self.updated
@@ -72,7 +69,6 @@ struct ScheduleDocumentTests {
// quoted key "updatedAt" contains it as a substring.) // quoted key "updatedAt" contains it as a substring.)
#expect(!json.contains("goal")) #expect(!json.contains("goal"))
#expect(!json.contains("weekdays")) #expect(!json.contains("weekdays"))
#expect(!json.contains("timesPerWeek"))
#expect(!json.contains("\"date\"")) #expect(!json.contains("\"date\""))
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
@@ -80,7 +76,6 @@ struct ScheduleDocumentTests {
#expect(decoded.goal == nil) #expect(decoded.goal == nil)
#expect(decoded.goalKind == nil) #expect(decoded.goalKind == nil)
#expect(decoded.weekdays == nil) #expect(decoded.weekdays == nil)
#expect(decoded.timesPerWeek == nil)
#expect(decoded.date == nil) #expect(decoded.date == nil)
} }
@@ -91,7 +86,7 @@ struct ScheduleDocumentTests {
id: ULID.make(), routineID: "R", routineName: "Trail Run", id: ULID.make(), routineID: "R", routineName: "Trail Run",
goal: GoalKind.cardio.rawValue, goal: GoalKind.cardio.rawValue,
recurrence: ScheduleRecurrence.once.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 createdAt: Self.created, updatedAt: Self.updated
) )
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original)) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
@@ -99,23 +94,6 @@ struct ScheduleDocumentTests {
#expect(decoded.recurrenceEnum == .once) #expect(decoded.recurrenceEnum == .once)
#expect(decoded.date == Self.onceDay) #expect(decoded.date == Self.onceDay)
#expect(decoded.weekdays == nil) #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 // MARK: - Forward-compatibility gate
@@ -127,7 +105,7 @@ struct ScheduleDocumentTests {
func doc(schema: Int) -> ScheduleDocument { func doc(schema: Int) -> ScheduleDocument {
ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N", ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N",
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
weekdays: nil, timesPerWeek: nil, order: 0, weekdays: nil, order: 0,
createdAt: Self.created, updatedAt: Self.updated) createdAt: Self.created, updatedAt: Self.updated)
} }
#expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable) #expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable)
@@ -145,38 +123,34 @@ struct ScheduleDocumentTests {
// Once the one-off day, medium-date formatted (routing check; the format // Once the one-off day, medium-date formatted (routing check; the format
// itself is `Date.formatDate()`'s). Degenerate nil date the display name. // 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, date: Self.onceDay) == Self.onceDay.formatDate())
#expect(summary(.once, weekdays: nil, timesPerWeek: nil) == "Once") #expect(summary(.once, weekdays: nil) == "Once")
// Daily. // 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. // 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. // 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". // 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. // Degenerate (no weekdays) falls back to the recurrence display name.
#expect(summary(.fixedDays, weekdays: [], timesPerWeek: nil) == "Fixed Days") #expect(summary(.fixedDays, weekdays: []) == "Fixed Days")
#expect(summary(.fixedDays, weekdays: nil, timesPerWeek: nil) == "Fixed Days") #expect(summary(.fixedDays, weekdays: nil) == "Fixed Days")
// Out-of-range weekday numbers are ignored. // Out-of-range weekday numbers are ignored.
#expect(summary(.fixedDays, weekdays: [0, 2, 9], timesPerWeek: nil) == sym[1]) #expect(summary(.fixedDays, weekdays: [0, 2, 9]) == sym[1])
// Frequency.
#expect(summary(.frequency, weekdays: nil, timesPerWeek: 2) == "2× per week")
#expect(summary(.frequency, weekdays: nil, timesPerWeek: nil) == "0× per week")
} }
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( let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N", 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 date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated
) )
// The entity must format identically to the document this is the shared-logic check. // 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, 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, date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated,
jsonRelativePath: "") jsonRelativePath: "")
#expect(doc.recurrenceSummary == entity.recurrenceSummary) #expect(doc.recurrenceSummary == entity.recurrenceSummary)
@@ -205,7 +179,6 @@ struct ScheduleDocumentTests {
goal: GoalKind.mobility.rawValue, goal: GoalKind.mobility.rawValue,
recurrence: ScheduleRecurrence.fixedDays.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue,
weekdays: [2, 4, 6], weekdays: [2, 4, 6],
timesPerWeek: nil,
order: 7, order: 7,
createdAt: Self.created, createdAt: Self.created,
updatedAt: Self.updated updatedAt: Self.updated
@@ -222,7 +195,6 @@ struct ScheduleDocumentTests {
#expect(entity.recurrenceRaw == original.recurrence) #expect(entity.recurrenceRaw == original.recurrence)
#expect(entity.recurrenceEnum == .fixedDays) #expect(entity.recurrenceEnum == .fixedDays)
#expect(entity.weekdays == [2, 4, 6]) #expect(entity.weekdays == [2, 4, 6])
#expect(entity.timesPerWeek == nil)
#expect(entity.order == 7) #expect(entity.order == 7)
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json") #expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
@@ -240,7 +212,7 @@ struct ScheduleDocumentTests {
schemaVersion: ScheduleDocument.currentSchemaVersion, schemaVersion: ScheduleDocument.currentSchemaVersion,
id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run", id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run",
goal: nil, recurrence: ScheduleRecurrence.once.rawValue, 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 createdAt: Self.created, updatedAt: Self.updated
) )
CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context) CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
@@ -267,7 +239,7 @@ struct ScheduleDocumentTests {
schemaVersion: ScheduleDocument.currentSchemaVersion, schemaVersion: ScheduleDocument.currentSchemaVersion,
id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name", id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name",
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
weekdays: nil, timesPerWeek: nil, order: 0, weekdays: nil, order: 0,
createdAt: Self.created, updatedAt: Self.created createdAt: Self.created, updatedAt: Self.created
) )
CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context) CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context)
@@ -276,8 +248,8 @@ struct ScheduleDocumentTests {
var second = first var second = first
second.routineName = "New Name" second.routineName = "New Name"
second.goal = GoalKind.strength.rawValue second.goal = GoalKind.strength.rawValue
second.recurrence = ScheduleRecurrence.frequency.rawValue second.recurrence = ScheduleRecurrence.fixedDays.rawValue
second.timesPerWeek = 4 second.weekdays = [2, 4]
second.updatedAt = Self.updated second.updatedAt = Self.updated
CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context) CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
try context.save() try context.save()
@@ -287,8 +259,8 @@ struct ScheduleDocumentTests {
let entity = try #require(all.first) let entity = try #require(all.first)
#expect(entity.routineName == "New Name") #expect(entity.routineName == "New Name")
#expect(entity.goalKind == .strength) #expect(entity.goalKind == .strength)
#expect(entity.recurrenceEnum == .frequency) #expect(entity.recurrenceEnum == .fixedDays)
#expect(entity.timesPerWeek == 4) #expect(entity.weekdays == [2, 4])
#expect(ScheduleDocument(from: entity) == second) #expect(ScheduleDocument(from: entity) == second)
} }
} }