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**
|
**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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 (Mon…Sat = 2…7, Sun = 1).
|
/// Calendar weekday numbers in Monday-first display order (Mon…Sat = 2…7, 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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (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
|
// 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user