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
+180 -93
View File
@@ -55,13 +55,13 @@ final class SyncEngine {
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
/// notifies on change); not persisted durability comes from `repointWorkouts`,
/// which rewrites `splitID` on the workout documents themselves at fork time.
/// which rewrites `routineID` on the workout documents themselves at fork time.
/// This map only bridges views that captured the seed's id before the swap.
private(set) var cloneRedirects: [String: String] = [:]
/// Follow the redirect chain from `id` to the current live split id. A seed
/// Follow the redirect chain from `id` to the current live routine id. A seed
/// redirects at most once, but the loop tolerates (and breaks) any chain or cycle.
func currentSplitID(for id: String) -> String {
func currentRoutineID(for id: String) -> String {
var current = id
var seen: Set<String> = [current]
while let next = cloneRedirects[current], seen.insert(next).inserted {
@@ -74,6 +74,14 @@ final class SyncEngine {
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
/// Fired when a phone-side save first moves a workout from `.notStarted` into
/// `.inProgress` the moment a run actually begins. AppServices launches the
/// watch session here rather than at creation, so peeking into a routine never
/// spins one up. Watch-originated updates (`ingestFromWatch`) never fire it
/// the watch already owns a session and neither does editing an old finished
/// workout back to in-progress (the transition must be from `.notStarted`).
var onWorkoutBecameActive: ((WorkoutDocument) -> Void)?
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
private let modelContainer: ModelContainer
private var store: DocumentFileStore?
@@ -176,7 +184,9 @@ final class SyncEngine {
}
}
log.info("connect[\(attempt)]: preparing directories…")
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
// PINNED: the iCloud directory is "Splits" (routine documents live under it).
// The directory name is on-disk data the SplitRoutine rename must not touch it.
await store.prepareDirectories(["Splits", "Workouts", "Schedules", "Stubs"])
safety.cancel()
guard attempt == connectAttempt else { return }
@@ -323,21 +333,21 @@ final class SyncEngine {
onCacheChanged?()
return
}
await save(workout: merged)
await save(workout: merged, notifyingActive: false)
} else {
// First time we've seen this workout nothing to merge against.
await save(workout: doc)
await save(workout: doc, notifyingActive: false)
}
}
// MARK: - Public CRUD (mirror-first: cache now, file via the write queue)
/// Returns the *effective* id of the split that was written normally `doc.id`,
/// Returns the *effective* id of the routine that was written normally `doc.id`,
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
/// via `currentSplitID(for:)`.
/// via `currentRoutineID(for:)`.
@discardableResult
func save(split doc: SplitDocument) async -> String {
// Seeds are immutable: a real edit forks the seed into a user-owned split and
func save(routine doc: RoutineDocument) async -> String {
// Seeds are immutable: a real edit forks the seed into a user-owned routine and
// soft-deletes the original, keeping the curated seed intact and restorable. A
// pristine (no-op) save must NOT fork the edit sheets stamp `updatedAt`
// unconditionally, so merely opening and saving a seed's editor without a change
@@ -346,19 +356,19 @@ final class SyncEngine {
return await cloneSeedOnEdit(doc)
}
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
CacheMapper.upsertRoutine(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(.split(doc), timestamp: doc.updatedAt)
enqueueWrite(.routine(doc), timestamp: doc.updatedAt)
return doc.id
}
/// Fork an edited seed into a user-owned split. Writes the clone under a fresh
/// Fork an edited seed into a user-owned routine. Writes the clone under a fresh
/// ULID and upserts it into the cache immediately (same rationale as
/// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata
/// observer, and open screens must see the clone the moment `save` returns), then
/// soft-deletes the seed and drops its cache entity. Records the id redirect so a
/// view still holding the seed's id follows the identity swap.
private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String {
private func cloneSeedOnEdit(_ doc: RoutineDocument) async -> String {
guard let store else { return doc.id }
var clone = doc
clone.id = ULID.make()
@@ -366,9 +376,10 @@ final class SyncEngine {
// Exercise ids are kept as-is they only need uniqueness within the document.
do {
try await store.write(clone, to: clone.relativePath)
CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
CacheMapper.upsertRoutine(clone, relativePath: clone.relativePath, into: context)
// Soft-delete the seed (writes its veto stub, then removes the live file),
// then evict its now-orphaned cache entity.
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath)
deleteCachedEntity(id: doc.id)
try context.save()
@@ -378,63 +389,73 @@ final class SyncEngine {
onCacheChanged?()
return clone.id
} catch {
report("Failed to fork edited starter split", error)
report("Failed to fork edited starter routine", error)
return doc.id
}
}
/// Rewrite `splitID` on every workout that references `oldID`, so split lookups
/// Rewrite `routineID` on every workout that references `oldID`, so routine lookups
/// (category grouping, add-exercise, plan mirroring, health estimates) keep
/// resolving after a seed fork durably, across relaunches, and on the watch,
/// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen
/// which the in-memory `cloneRedirects` map can't reach. `routineName` stays frozen
/// at what the workout was started as, matching rename semantics for regular
/// splits. Best effort per workout: a failed rewrite is reported and the redirect
/// routines. Best effort per workout: a failed rewrite is reported and the redirect
/// map still covers it for this session.
private func repointWorkouts(from oldID: String, to newID: String) async {
guard let store else { return }
let referencing = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == oldID })
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == oldID })
)) ?? []
guard !referencing.isEmpty else { return }
for workout in referencing {
var wDoc = WorkoutDocument(from: workout)
wDoc.splitID = newID
wDoc.routineID = newID
wDoc.updatedAt = Date()
do {
try await store.write(wDoc, to: wDoc.relativePath)
CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context)
} catch {
report("Failed to repoint workout \(wDoc.id) at edited split", error)
report("Failed to repoint workout \(wDoc.id) at edited routine", error)
}
}
do { try context.save() } catch { report("Cache save failed", error) }
}
/// Push edited machine settings onto the originating split's exercise (matched
/// Push edited machine settings onto the originating routine's exercise (matched
/// by name logs reference exercises by name only), following the seed
/// clone-on-edit redirect so the live clone is updated. Skips silently when the
/// split is gone or no exercise matches, and when nothing changed (so a pristine
/// seed isn't needlessly forked). Never caches the split's id across the save,
/// routine is gone or no exercise matches, and when nothing changed (so a pristine
/// seed isn't needlessly forked). Never caches the routine's id across the save,
/// since saving a seed mints a new one.
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
guard let splitID else { return }
let liveID = currentSplitID(for: splitID)
guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
var splitDoc = SplitDocument(from: split)
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard splitDoc.exercises[idx].machineSettings != settings else { return }
splitDoc.exercises[idx].machineSettings = settings
splitDoc.updatedAt = Date()
await save(split: splitDoc)
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, routineID: String?) async {
guard let routineID else { return }
let liveID = currentRoutineID(for: routineID)
guard let routine = CacheMapper.fetchRoutine(id: liveID, in: context) else { return }
var routineDoc = RoutineDocument(from: routine)
guard let idx = routineDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard routineDoc.exercises[idx].machineSettings != settings else { return }
routineDoc.exercises[idx].machineSettings = settings
routineDoc.updatedAt = Date()
await save(routine: routineDoc)
}
func save(workout doc: WorkoutDocument) async {
await save(workout: doc, notifyingActive: true)
}
/// `notifyingActive: false` is the watch-ingest path the watch already runs
/// its own session, so a watch-originated update must not fire the
/// became-active hook (which would launch a second session at the wrist).
private func save(workout doc: WorkoutDocument, notifyingActive: Bool) async {
// The month bucket in a workout's path derives from `start`, so editing the
// start date (or a device in a different time zone) can move the file to a new
// path. Capture the previously-written path before the upsert overwrites it
// the queued write removes it after landing, otherwise the same id would live
// at two paths and the old copy would re-import on the next reconcile.
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
// (Both reads happen before the upsert mutates the same entity in place.)
let previous = CacheMapper.fetchWorkout(id: doc.id, in: context)
let previousPath = previous?.jsonRelativePath
let previousStatus = previous?.status
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(
@@ -442,12 +463,26 @@ final class SyncEngine {
timestamp: doc.updatedAt,
stalePath: previousPath != doc.relativePath ? previousPath : nil
)
if notifyingActive, previousStatus == .notStarted,
doc.status == WorkoutStatus.inProgress.rawValue {
onWorkoutBecameActive?(doc)
}
}
func delete(split: Split) async {
let id = split.id, livePath = split.jsonRelativePath
/// Persist a schedule. Schedules live in a flat `Schedules/` directory (no month
/// bucketing), so unlike `save(workout:)` a save can never move the file to a
/// new path: mirror the cache, then queue the plain write.
func save(schedule doc: ScheduleDocument) async {
CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(.schedule(doc), timestamp: doc.updatedAt)
}
func delete(routine: Routine) async {
let id = routine.id, livePath = routine.jsonRelativePath
deleteCachedEntity(id: id)
saveCacheAndNotify()
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date())
}
@@ -458,6 +493,13 @@ final class SyncEngine {
enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date())
}
func delete(schedule: Schedule) async {
let id = schedule.id, livePath = schedule.jsonRelativePath
deleteCachedEntity(id: id)
saveCacheAndNotify()
enqueueWrite(.delete(id: id, kind: "schedule", livePath: livePath), timestamp: Date())
}
/// Persist pending cache mutations and fan out the change notification the
/// tail of every local write's immediate cache mirror.
private func saveCacheAndNotify() {
@@ -552,10 +594,12 @@ final class SyncEngine {
guard let store, let tombstones else { return .retry }
do {
switch entry.payload {
case .split(let doc):
case .routine(let doc):
try await store.write(doc, to: doc.relativePath)
case .workout(let doc):
try await store.write(doc, to: doc.relativePath)
case .schedule(let doc):
try await store.write(doc, to: doc.relativePath)
case .delete(let id, let kind, let livePath):
// The stub is the authoritative delete record (it vetoes
// resurrection everywhere); the live-file removal is best-effort
@@ -635,16 +679,23 @@ final class SyncEngine {
return
}
// PINNED: routine documents live under the "Splits/" directory on disk the
// path prefix is data, unchanged by the SplitRoutine symbol rename.
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
CacheMapper.upsertRoutine(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Schedules/") {
guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertSchedule(doc, relativePath: relativePath, into: context)
}
}
@@ -661,8 +712,9 @@ final class SyncEngine {
let tombstoned = await tombstones.listStubIDs()
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
var liveSplitIDs = Set<String>()
var liveRoutineIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
var liveScheduleIDs = Set<String>()
var unreadablePaths = Set<String>()
for path in dataFiles {
@@ -680,18 +732,25 @@ final class SyncEngine {
unreadablePaths.insert(path)
continue
}
// PINNED: routine documents live under "Splits/" on disk (see importFile).
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveSplitIDs.insert(doc.id)
liveRoutineIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
CacheMapper.upsertRoutine(doc, relativePath: path, into: context)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveWorkoutIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
} else if path.hasPrefix("Schedules/") {
guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveScheduleIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertSchedule(doc, relativePath: path, into: context)
}
}
@@ -700,8 +759,8 @@ final class SyncEngine {
// right now; pruning would make an eviction look like a deletion), and
// never for an id with a queued-but-unwritten write (its file simply
// hasn't landed yet; pruning would evaporate the pending edit's mirror).
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id)
if let routines = try? context.fetch(FetchDescriptor<Routine>()) {
for s in routines where !liveRoutineIDs.contains(s.id)
&& !unreadablePaths.contains(s.jsonRelativePath)
&& backlog.pendingWrite(for: s.id) == nil {
context.delete(s)
@@ -714,6 +773,13 @@ final class SyncEngine {
context.delete(w)
}
}
if let schedules = try? context.fetch(FetchDescriptor<Schedule>()) {
for s in schedules where !liveScheduleIDs.contains(s.id)
&& !unreadablePaths.contains(s.jsonRelativePath)
&& backlog.pendingWrite(for: s.id) == nil {
context.delete(s)
}
}
do {
try context.save()
// A fully clean pass supersedes any earlier failure; a pass with
@@ -728,8 +794,9 @@ final class SyncEngine {
// MARK: - Cache deletes
private func deleteCachedEntity(id: String) {
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
if let s = CacheMapper.fetchRoutine(id: id, in: context) { context.delete(s) }
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
if let sc = CacheMapper.fetchSchedule(id: id, in: context) { context.delete(sc) }
}
/// Observer `.removed` handling. Skips entities with a queued-but-unwritten
@@ -737,12 +804,15 @@ final class SyncEngine {
/// raced the rewrite), and the pending edit's mirror must survive until the
/// drain delivers it.
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
if let routines = try? context.fetch(FetchDescriptor<Routine>(predicate: #Predicate { $0.jsonRelativePath == path })) {
routines.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
if let schedules = try? context.fetch(FetchDescriptor<Schedule>(predicate: #Predicate { $0.jsonRelativePath == path })) {
schedules.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
}
private func idFromStubPath(_ path: String) -> String {
@@ -804,7 +874,7 @@ final class SyncEngine {
// Verbatim bundle bytes: byte-identical files across devices make a
// same-path conflict (two devices seeding at once) semantically empty.
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
} catch {
report("Failed to seed \(seed.doc.name)", error)
}
@@ -837,7 +907,7 @@ final class SyncEngine {
/// live file present, older revision overwrite with bundle bytes (upgrade)
/// live file present, newer app version skip (quarantine never downgrade)
/// no live file, stub present skip (user deleted it; veto stands)
/// no live file, no stub, name in use skip (don't duplicate a same-name split)
/// no live file, no stub, name in use skip (don't duplicate a same-name routine)
/// no live file, no stub, name free write bundle bytes
///
/// Safe even on a stale metadata index: writing bundle bytes another device also
@@ -848,17 +918,17 @@ final class SyncEngine {
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
let stubIDs = await tombstones.listStubIDs()
let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let liveRoutineNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
var didChange = false
for seed in SeedLibrary.seeds {
// Decode the live file at the seed's fixed path, if one exists. A file that
// exists but can't be read right now (evicted + download timed out) is left
// untouched a later connect retries it.
var liveDoc: SplitDocument?
var liveDoc: RoutineDocument?
if livePaths.contains(seed.doc.relativePath) {
guard let data = try? await store.readData(from: seed.doc.relativePath),
let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else {
let decoded = try? DocumentCoder.decode(RoutineDocument.self, from: data) else {
continue
}
liveDoc = decoded
@@ -869,7 +939,7 @@ final class SyncEngine {
hasStub: stubIDs.contains(seed.id)
)
switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
switch SeedReconcilePlanner.decision(for: input, liveRoutineNames: liveRoutineNames) {
case .skip:
continue
case .upgrade:
@@ -880,7 +950,7 @@ final class SyncEngine {
// rewriting the seed bytes here would transiently resurrect the deleted
// seed alongside the user's clone. The stub now present vetoes that.
// (No name-collision guard here: on a legitimate upgrade the seed itself
// is a live split by that name, so a name check would block every upgrade.)
// is a live routine by that name, so a name check would block every upgrade.)
if await tombstones.stubExists(id: seed.id) { continue }
// Overwriting an existing seed-path file with canonical bundle bytes is
// otherwise always safe (the file can only ever be an older seed revision).
@@ -889,7 +959,7 @@ final class SyncEngine {
// Re-check the veto and name guards against fresh state the metadata
// index may have moved since the batch was gathered.
if await tombstones.stubExists(id: seed.id) { continue }
let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let names = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
if names.contains(seed.doc.name) { continue }
if await writeSeedBytes(seed) { didChange = true }
}
@@ -902,7 +972,7 @@ final class SyncEngine {
}
/// Force-restore the starter library on explicit user request ("Restore Starter
/// Splits"). For each seed with no live file and no live same-name split, lift its
/// Routines"). For each seed with no live file and no live same-name routine, lift its
/// veto stub if present the ONE place the forever-veto is deliberately lifted
/// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is
/// the canonical restore source). Seeds whose live file already exists are left for
@@ -915,11 +985,11 @@ final class SyncEngine {
var restored = 0
for seed in SeedLibrary.seeds {
let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
guard SeedReconcilePlanner.shouldRestore(
hasLiveFile: livePaths.contains(seed.doc.relativePath),
seedName: seed.doc.name,
liveSplitNames: liveNames
liveRoutineNames: liveNames
) else { continue }
do {
@@ -929,11 +999,11 @@ final class SyncEngine {
try await tombstones.removeStub(at: "\(seed.id).json")
}
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
restored += 1
lastSyncError = nil
} catch {
report("Failed to restore starter split \(seed.doc.name)", error)
report("Failed to restore starter routine \(seed.doc.name)", error)
}
}
@@ -951,11 +1021,11 @@ final class SyncEngine {
guard let store else { return false }
do {
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
lastSyncError = nil
return true
} catch {
report("Failed to reconcile starter split \(seed.doc.name)", error)
report("Failed to reconcile starter routine \(seed.doc.name)", error)
return false
}
}
@@ -968,18 +1038,18 @@ final class SyncEngine {
}
/// Scans every live (non-stub) document and builds a plan for exact-content
/// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only no
/// duplicate routines/workouts (see `DuplicateCleanupPlanner`). Read-only no
/// files are touched. Fails closed: if ANY file can't be read or decoded, no
/// plan is produced, because an unreadable workout could reference any split
/// and silently proceeding could misjudge that split as unreferenced.
/// plan is produced, because an unreadable workout or schedule could reference
/// any routine and silently proceeding could misjudge it as unreferenced.
func scanForDuplicates() async throws -> DuplicateCleanupPlan {
guard let store else { throw DuplicateScanError.notConnected }
let paths = await store.list().filter { !$0.hasPrefix("Stubs/") }
var splits: [SplitDocument] = []
var routines: [RoutineDocument] = []
var workouts: [WorkoutDocument] = []
var referencedSplitIDs = Set<String>()
var referencedRoutineIDs = Set<String>()
var failedPaths: [String] = []
for path in paths {
@@ -992,12 +1062,13 @@ final class SyncEngine {
continue
}
// PINNED: routine documents live under "Splits/" on disk (see importFile).
if path.hasPrefix("Splits/") {
do {
let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
// Quarantined (written by a newer app version) splits are never
let doc = try DocumentCoder.decode(RoutineDocument.self, from: data)
// Quarantined (written by a newer app version) routines are never
// judged as duplicates or deleted.
if doc.isReadable { splits.append(doc) }
if doc.isReadable { routines.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
@@ -1005,17 +1076,29 @@ final class SyncEngine {
} else if path.hasPrefix("Workouts/") {
do {
let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
// A quarantined workout's splitID still protects that split from
// A quarantined workout's routineID still protects that routine from
// deletion even though the workout itself is excluded below.
if let splitID = doc.splitID {
referencedSplitIDs.insert(splitID)
referencedSplitIDs.insert(currentSplitID(for: splitID))
if let routineID = doc.routineID {
referencedRoutineIDs.insert(routineID)
referencedRoutineIDs.insert(currentRoutineID(for: routineID))
}
if doc.isReadable { workouts.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
} else if path.hasPrefix("Schedules/") {
do {
let doc = try DocumentCoder.decode(ScheduleDocument.self, from: data)
// Schedules are never duplicate candidates themselves, but the
// routine a schedule (even a quarantined one) points at must
// survive cleanup, or the schedule would dangle.
referencedRoutineIDs.insert(doc.routineID)
referencedRoutineIDs.insert(currentRoutineID(for: doc.routineID))
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
}
}
@@ -1023,12 +1106,12 @@ final class SyncEngine {
throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
}
referencedSplitIDs.formUnion(cloneRedirects.values)
referencedRoutineIDs.formUnion(cloneRedirects.values)
return DuplicateCleanupPlanner.plan(
splits: splits,
routines: routines,
workouts: workouts,
referencedSplitIDs: referencedSplitIDs,
referencedRoutineIDs: referencedRoutineIDs,
isSeed: SeedLibrary.isSeed(id:)
)
}
@@ -1037,32 +1120,36 @@ final class SyncEngine {
/// immediately beforehand against the *current* cache state a watch push or
/// another device's write can land between scan and delete so nothing
/// referenced or active is ever removed even if the plan is a few seconds stale.
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
var splitsDeleted = 0
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (routinesDeleted: Int, workoutsDeleted: Int, skipped: Int) {
var routinesDeleted = 0
var workoutsDeleted = 0
var skipped = 0
for group in plan.splitGroups {
for group in plan.routineGroups {
for doc in group.delete {
let splitID = doc.id
if SeedLibrary.isSeed(id: splitID) {
let routineID = doc.id
if SeedLibrary.isSeed(id: routineID) {
skipped += 1
continue
}
let referencingWorkouts = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == routineID })
)) ?? []
guard referencingWorkouts.isEmpty else {
let referencingSchedules = (try? context.fetch(
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == routineID })
)) ?? []
guard referencingWorkouts.isEmpty, referencingSchedules.isEmpty else {
skipped += 1
continue
}
if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
if let cached = CacheMapper.fetchRoutine(id: routineID, in: context) {
await softDelete(id: routineID, kind: "split", livePath: cached.jsonRelativePath)
} else {
await softDelete(id: splitID, kind: "split", livePath: doc.relativePath)
await softDelete(id: routineID, kind: "split", livePath: doc.relativePath)
}
deleteCachedEntity(id: splitID)
splitsDeleted += 1
deleteCachedEntity(id: routineID)
routinesDeleted += 1
}
}
@@ -1081,7 +1168,7 @@ final class SyncEngine {
}
saveCacheAndNotify()
return (splitsDeleted, workoutsDeleted, skipped)
return (routinesDeleted, workoutsDeleted, skipped)
}
// MARK: - Error Reporting