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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 (Mon…Sat = 2…7, Sun = 1).
|
||||
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
|
||||
/// Gregorian short names, 0-indexed from Sunday — weekday `n` is `symbols[n - 1]`.
|
||||
@@ -40,17 +85,25 @@ struct ScheduleAddEditView: View {
|
||||
|
||||
/// `defaultDate` seeds the `.once` day when adding — the Today board passes the day
|
||||
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
|
||||
init(schedule: Schedule? = nil, defaultDate: Date = Date()) {
|
||||
init(schedule: Schedule? = nil, defaultDate: Date = Date(),
|
||||
onStartNow: @escaping (Routine) -> Void = { _ in }) {
|
||||
_scheduleID = State(initialValue: schedule?.id)
|
||||
_order = State(initialValue: schedule?.order ?? 0)
|
||||
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
|
||||
_routineID = State(initialValue: schedule?.routineID ?? "")
|
||||
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
|
||||
_goal = State(initialValue: schedule?.goalKind)
|
||||
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
|
||||
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
|
||||
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
|
||||
_date = State(initialValue: schedule?.date ?? defaultDate)
|
||||
self.onStartNow = onStartNow
|
||||
|
||||
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
|
||||
self.offersNow = offersNow
|
||||
if let schedule {
|
||||
_when = State(initialValue: WhenChoice(recurrence: schedule.recurrenceEnum))
|
||||
} else {
|
||||
_when = State(initialValue: offersNow ? .now : .once)
|
||||
}
|
||||
}
|
||||
|
||||
private var isEditing: Bool { scheduleID != nil }
|
||||
@@ -60,7 +113,7 @@ struct ScheduleAddEditView: View {
|
||||
|
||||
private var canSave: Bool {
|
||||
guard selectedRoutine != nil else { return false }
|
||||
if recurrence == .fixedDays { return !weekdays.isEmpty }
|
||||
if when == .fixedDays { return !weekdays.isEmpty }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -68,17 +121,20 @@ struct ScheduleAddEditView: View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
routineSection
|
||||
if when != .now {
|
||||
goalSection
|
||||
recurrenceSection
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
|
||||
whenSection
|
||||
}
|
||||
.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,
|
||||
|
||||
@@ -41,16 +41,37 @@ 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 }
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-empty goal groups in the user's chosen goal order.
|
||||
private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (Wed–Sun) in that week, so a 7x/week target
|
||||
// clamps down to 5, not 7.
|
||||
let createdMidWeek = schedule(recurrence: .frequency, timesPerWeek: 7, createdAt: day("2026-07-08"))
|
||||
let clamped = ProgressPlanner.weekMark(schedule: createdMidWeek, doneDays: [],
|
||||
weekStart: currentWeek, calendar: cal, now: day("2026-07-12"))
|
||||
#expect(clamped?.expected == 5)
|
||||
}
|
||||
|
||||
// MARK: - weekMark: once
|
||||
|
||||
@Test func weekMarkOnce() {
|
||||
@@ -247,25 +215,6 @@ struct ProgressPlannerTests {
|
||||
weekStart: weekStart, calendar: cal, now: now))
|
||||
}
|
||||
|
||||
@Test func currentWeekBrokenFrequencyDaysRemainingVsRequirement() {
|
||||
let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01"))
|
||||
let weekStart = day("2026-07-06")
|
||||
let oneDone: Set<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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user