Restructure into a three-tab app with Progress, goals, and Meditation

The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
+76 -12
View File
@@ -5,16 +5,16 @@ import IndieSync
/// entities so the wire format can evolve without dragging the cache schema.
///
/// One document = one aggregate root:
/// `SplitDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `RoutineDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `WorkoutDocument` embeds its `[WorkoutLogDocument]` `Workouts/YYYY/MM/<ULID>.json`
///
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
/// at sync time, and forward-gates files written by a newer app version.
/// These documents are also the wire format for the iPhoneWatch bridge.
// MARK: - Split
// MARK: - Routine
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
struct RoutineDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var name: String
@@ -29,7 +29,7 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
/// older app dropping it on rewrite (reverting to the default) is preferable to
/// quarantining the user's whole routine.
var activityType: Int?
/// Per-split rest length (seconds) and flow-mode toggle. Both optional and
/// Per-routine rest length (seconds) and flow-mode toggle. Both optional and
/// deliberately NOT schema-bumped, same rationale as `activityType`: an older
/// app dropping them on rewrite reverts to the global default rest / no-flow,
/// which is preferable to quarantining the user's whole routine.
@@ -46,6 +46,9 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
// quarantine rather than rewrite.
static let currentSchemaVersion = 3
// PINNED: the iCloud directory name stays "Splits/" even though the type is now
// `RoutineDocument`. Existing user files live under Splits/ the directory name
// is on-disk data, not a symbol, and must not change on the rename.
var relativePath: String { "Splits/\(id).json" }
}
@@ -87,8 +90,8 @@ struct SetEntry: Codable, Sendable, Equatable {
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID (chronological)
var splitID: String?
var splitName: String?
var routineID: String?
var routineName: String?
var start: Date
var end: Date?
var status: String // WorkoutStatus raw value
@@ -109,13 +112,35 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the split's rest length / flow flag at plan time (a running
/// workout has no live link to its split, same reason sets/reps/weight are
/// Snapshot of the routine's rest length / flow flag at plan time (a running
/// workout has no live link to its routine, same reason sets/reps/weight are
/// snapshotted per log). Optional and not schema-bumped, same rationale as
/// `SplitDocument.restSeconds`/`autoAdvance`.
/// `RoutineDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhoneWatch wire) JSON keys
// stay `"splitID"`/`"splitName"` so existing files and a not-yet-updated peer still
// decode. Every stored property must be enumerated here (the computed `relativePath`
// and static members are not coded); all others keep their default key names.
enum CodingKeys: String, CodingKey {
case schemaVersion
case id
case routineID = "splitID"
case routineName = "splitName"
case start
case end
case status
case createdAt
case updatedAt
case logs
case metrics
case deletedLogIDs
case restSeconds
case autoAdvance
}
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite.
@@ -180,7 +205,7 @@ extension WorkoutDocument {
/// End the workout now, keeping progress: mark every not-completed log as skipped, then
/// recompute so it resolves to `.completed` (with `end` stamped). This is the
/// "End Workout Save" operation, shared by the in-workout menu and the
/// start-a-new-split prompt.
/// start-a-new-routine prompt.
mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].transition(to: .skipped)
@@ -222,7 +247,7 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
}
extension WorkoutLogDocument {
/// Build a fresh plan-time log from a split's exercise. Snapshots the plan
/// Build a fresh plan-time log from a routine's exercise. Snapshots the plan
/// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
/// running workout carries the exercise's settings as its own editable copy
/// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
@@ -337,9 +362,48 @@ struct WorkoutMetrics: Codable, Sendable, Equatable {
var recordedAt: Date
}
// MARK: - Schedule
/// The plan layer: one routine + a recurrence + an optional goal tag, persisted as
/// `Schedules/<ULID>.json`. The schedule is the join object between routines and
/// goals scheduling never touches the routine file (starter routines are
/// immutable fixed-ULID seeds).
struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var routineID: String
var routineName: String // denormalized display fallback (routine may be deleted)
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
var updatedAt: Date
static let currentSchemaVersion = 1
var relativePath: String { "Schedules/\(id).json" }
}
extension ScheduleDocument {
/// The tagged goal, or nil when unassigned / an unknown raw value.
var goalKind: GoalKind? { goal.flatMap(GoalKind.init(rawValue:)) }
/// 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").
var recurrenceSummary: String {
recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date)
}
}
// MARK: - Forward-compatibility gate
// `VersionedDocument` (the `isReadable` quarantine gate for files written by a
// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
extension SplitDocument: VersionedDocument {}
extension RoutineDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension ScheduleDocument: VersionedDocument {}
+69 -10
View File
@@ -11,10 +11,10 @@ import SwiftData
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
// MARK: - Split
// MARK: - Routine
@Model
final class Split {
final class Routine {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var color: String = "indigo"
@@ -27,7 +27,7 @@ final class Split {
var restSeconds: Int?
var autoAdvance: Bool?
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
var exercises: [Exercise] = []
init(id: String, name: String, color: String, systemImage: String, order: Int,
@@ -42,7 +42,7 @@ final class Split {
self.jsonRelativePath = jsonRelativePath
}
static let unnamed = "Unnamed Split"
static let unnamed = "Unnamed Routine"
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
@@ -70,7 +70,7 @@ final class Exercise {
// machine exercise; empty a machine exercise with nothing recorded yet.
var machineSettings: [MachineSetting]?
var split: Split?
var routine: Routine?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
@@ -124,8 +124,8 @@ final class Exercise {
@Model
final class Workout {
@Attribute(.unique) var id: String = ULID.make()
var splitID: String?
var splitName: String?
var routineID: String?
var routineName: String?
var start: Date = Date()
var end: Date?
var statusRaw: String = WorkoutStatus.notStarted.rawValue
@@ -156,11 +156,11 @@ final class Workout {
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
init(id: String, routineID: String?, routineName: String?, start: Date, end: Date?,
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.splitID = splitID
self.splitName = splitName
self.routineID = routineID
self.routineName = routineName
self.start = start
self.end = end
self.statusRaw = statusRaw
@@ -174,6 +174,14 @@ final class Workout {
set { statusRaw = newValue.rawValue }
}
/// True while this workout is an untouched draft: created by a start tap, but no
/// exercise has ever left `.notStarted`. Peeking into a routine must leave no
/// trace, so the start paths discard a pristine draft when the user backs out of
/// it instead of persisting a phantom "in progress" workout.
var isPristineDraft: Bool {
status == .notStarted && logs.allSatisfy { $0.status == .notStarted }
}
var statusName: String { status.displayName }
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
@@ -298,3 +306,54 @@ final class WorkoutLog {
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Schedule
@Model
final class Schedule {
@Attribute(.unique) var id: String = ULID.make()
var routineID: String = ""
var routineName: String = ""
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()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
init(id: String, routineID: String, routineName: String, goalRaw: String?,
recurrenceRaw: String, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil,
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.routineID = routineID
self.routineName = routineName
self.goalRaw = goalRaw
self.recurrenceRaw = recurrenceRaw
self.weekdays = weekdays
self.timesPerWeek = timesPerWeek
self.date = date
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var goalKind: GoalKind? {
get { goalRaw.flatMap(GoalKind.init(rawValue:)) }
set { goalRaw = newValue?.rawValue }
}
var recurrenceEnum: ScheduleRecurrence {
get { ScheduleRecurrence(rawValue: recurrenceRaw) ?? .daily }
set { recurrenceRaw = newValue.rawValue }
}
/// Human-readable recurrence line shares its formatting with `ScheduleDocument`
/// via `ScheduleRecurrence.summary(weekdays:timesPerWeek:date:)`.
var recurrenceSummary: String {
recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date)
}
}
+128 -2
View File
@@ -35,9 +35,9 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
}
}
/// The kind of training a split represents used to tag the watch's Apple Health
/// The kind of training a routine represents used to tag the watch's Apple Health
/// workout so it lands in the right category (and credits the rings correctly).
/// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType`
/// Persisted as its raw `Int` on `RoutineDocument`; the `HKWorkoutActivityType`
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
/// HealthKit-free.
enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
@@ -49,6 +49,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case cycling = 5
case warmUp = 6
case stretching = 7
case mindAndBody = 8
var displayName: String {
switch self {
@@ -60,6 +61,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .cycling: "Cycling"
case .warmUp: "Warm-Up"
case .stretching: "Stretching"
case .mindAndBody: "Mind & Body"
}
}
@@ -73,6 +75,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .cycling: "figure.outdoor.cycle"
case .warmUp: "figure.arms.open"
case .stretching: "figure.flexibility"
case .mindAndBody: "figure.mind.and.body"
}
}
}
@@ -119,3 +122,126 @@ enum WeightUnit: String, CaseIterable, Codable, Sendable {
/// Render a stored weight value with the current unit's label, e.g. "135 lb".
func format(_ value: Int) -> String { "\(value) \(abbreviation)" }
}
// MARK: - Goals
/// The predefined practice-goal categories. Deliberately a small, closed set
/// schedules reference them by raw value, so raw values are stable persisted data.
enum GoalKind: String, CaseIterable, Codable, Sendable, Identifiable {
// Declaration order IS the initial board order (before any user reorder):
// the day reads top-to-bottom morning mobility, then cardio, then strength,
// with mindfulness closing it out.
case mobility, cardio, strength, mindfulness
var id: String { rawValue }
var displayName: String {
switch self {
case .strength: "Strength"
// Deliberately double-barreled: the same practice serves performance
// ("endurance") and health ("cardio") motivations the name excludes neither.
case .cardio: "Endurance/Cardio"
case .mobility: "Mobility"
case .mindfulness: "Mindfulness"
}
}
var systemImage: String {
switch self {
case .strength: "dumbbell.fill"
case .cardio: "figure.run"
case .mobility: "figure.flexibility"
case .mindfulness: "brain.head.profile"
}
}
/// A hue name `Color.color(from:)` understands. Four distinct hues so the goals
/// read apart at a glance.
var colorName: String {
switch self {
case .strength: "red"
case .cardio: "orange"
case .mobility: "teal"
case .mindfulness: "purple"
}
}
}
extension GoalKind {
static let orderDefaultsKey = "goalKindOrder"
/// All cases in the user's chosen order: stored raw values first (unknown ones
/// dropped, duplicates collapsed), then any cases missing from the stored list in
/// declaration order so newly added kinds appear automatically.
static func orderedCases(defaults: UserDefaults = .standard) -> [GoalKind] {
let stored = defaults.stringArray(forKey: orderDefaultsKey) ?? []
var ordered: [GoalKind] = []
var seen = Set<GoalKind>()
for raw in stored {
if let kind = GoalKind(rawValue: raw), seen.insert(kind).inserted {
ordered.append(kind)
}
}
for kind in GoalKind.allCases where !seen.contains(kind) {
ordered.append(kind)
}
return ordered
}
/// Persist the display order as `[String]` of raw values.
static func saveOrder(_ kinds: [GoalKind], defaults: UserDefaults = .standard) {
defaults.set(kinds.map(\.rawValue), forKey: orderDefaultsKey)
}
}
// MARK: - Schedules
/// How often a schedule expects its routine. Raw values are persisted in ScheduleDocument.
enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable {
case once, daily, fixedDays, frequency
var displayName: String {
switch self {
case .once: "Once"
case .daily: "Daily"
case .fixedDays: "Fixed Days"
case .frequency: "Times per Week"
}
}
}
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 {
switch self {
case .once:
return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once"
case .daily:
return displayName // "Daily"
case .fixedDays:
// A fixed Gregorian calendar's `shortWeekdaySymbols` is 0-indexed from
// Sunday, so weekday number `n` maps to index `n - 1` regardless of locale.
let symbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
let names = (weekdays ?? []).sorted().compactMap { n -> String? in
(1...7).contains(n) ? symbols[n - 1] : nil
}
return names.isEmpty ? displayName : Self.joined(names)
case .frequency:
return "\(timesPerWeek ?? 0)× per week"
}
}
/// Join weekday short names conversationally: "Mon", "Mon & Thu", "Mon, Wed & Fri".
private static func joined(_ names: [String]) -> String {
switch names.count {
case 0: return ""
case 1: return names[0]
case 2: return "\(names[0]) & \(names[1])"
default: return names.dropLast().joined(separator: ", ") + " & " + (names.last ?? "")
}
}
}
+79 -35
View File
@@ -21,14 +21,14 @@ extension ExerciseDocument {
}
}
extension SplitDocument {
init(from split: Split) {
self.init(schemaVersion: Self.currentSchemaVersion, id: split.id, name: split.name,
color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: split.activityTypeRaw,
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance)
extension RoutineDocument {
init(from routine: Routine) {
self.init(schemaVersion: Self.currentSchemaVersion, id: routine.id, name: routine.name,
color: routine.color, systemImage: routine.systemImage, order: routine.order,
createdAt: routine.createdAt, updatedAt: routine.updatedAt,
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: routine.activityTypeRaw,
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance)
}
}
@@ -46,8 +46,8 @@ extension WorkoutLogDocument {
extension WorkoutDocument {
init(from workout: Workout) {
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, splitID: workout.splitID,
splitName: workout.splitName, start: workout.start, end: workout.end,
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, routineID: workout.routineID,
routineName: workout.routineName, start: workout.start, end: workout.end,
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics,
@@ -86,11 +86,22 @@ extension WorkoutDocument {
logs: [])
}
extension ScheduleDocument {
init(from schedule: Schedule) {
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,
date: schedule.date,
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
}
}
// MARK: - Document Cache (upsert)
enum CacheMapper {
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
var d = FetchDescriptor<Split>(predicate: #Predicate { $0.id == id })
static func fetchRoutine(id: String, in context: ModelContext) -> Routine? {
var d = FetchDescriptor<Routine>(predicate: #Predicate { $0.id == id })
d.fetchLimit = 1
return try? context.fetch(d).first
}
@@ -101,30 +112,36 @@ enum CacheMapper {
return try? context.fetch(d).first
}
// MARK: Split
static func fetchSchedule(id: String, in context: ModelContext) -> Schedule? {
var d = FetchDescriptor<Schedule>(predicate: #Predicate { $0.id == id })
d.fetchLimit = 1
return try? context.fetch(d).first
}
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
let split: Split
if let existing = fetchSplit(id: doc.id, in: context) {
split = existing
// MARK: Routine
static func upsertRoutine(_ doc: RoutineDocument, relativePath: String, into context: ModelContext) {
let routine: Routine
if let existing = fetchRoutine(id: doc.id, in: context) {
routine = existing
} else {
split = Split(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
routine = Routine(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(split)
context.insert(routine)
}
split.name = doc.name
split.color = doc.color
split.systemImage = doc.systemImage
split.order = doc.order
split.createdAt = doc.createdAt
split.updatedAt = doc.updatedAt
split.jsonRelativePath = relativePath
split.activityTypeRaw = doc.activityType ?? 0
split.restSeconds = doc.restSeconds
split.autoAdvance = doc.autoAdvance
routine.name = doc.name
routine.color = doc.color
routine.systemImage = doc.systemImage
routine.order = doc.order
routine.createdAt = doc.createdAt
routine.updatedAt = doc.updatedAt
routine.jsonRelativePath = relativePath
routine.activityTypeRaw = doc.activityType ?? 0
routine.restSeconds = doc.restSeconds
routine.autoAdvance = doc.autoAdvance
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
for ed in doc.exercises {
keep.insert(ed.id)
@@ -132,11 +149,11 @@ enum CacheMapper {
apply(ed, to: e)
} else {
let e = makeExercise(ed)
e.split = split
e.routine = routine
context.insert(e)
}
}
for e in Array(split.exercises) where !keep.contains(e.id) {
for e in Array(routine.exercises) where !keep.contains(e.id) {
context.delete(e)
}
}
@@ -165,14 +182,14 @@ enum CacheMapper {
if let existing = fetchWorkout(id: doc.id, in: context) {
workout = existing
} else {
workout = Workout(id: doc.id, splitID: doc.splitID, splitName: doc.splitName,
workout = Workout(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
start: doc.start, end: doc.end, statusRaw: doc.status,
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(workout)
}
workout.splitID = doc.splitID
workout.splitName = doc.splitName
workout.routineID = doc.routineID
workout.routineName = doc.routineName
workout.start = doc.start
workout.end = doc.end
workout.statusRaw = doc.status
@@ -228,4 +245,31 @@ enum CacheMapper {
l.setEntries = d.setEntries
l.logUpdatedAt = d.updatedAt
}
// MARK: Schedule
static func upsertSchedule(_ doc: ScheduleDocument, relativePath: String, into context: ModelContext) {
let schedule: Schedule
if let existing = fetchSchedule(id: doc.id, in: context) {
schedule = existing
} 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,
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(schedule)
}
schedule.routineID = doc.routineID
schedule.routineName = doc.routineName
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
schedule.updatedAt = doc.updatedAt
schedule.jsonRelativePath = relativePath
}
}