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:
@@ -3,22 +3,22 @@ import Foundation
|
||||
// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O
|
||||
// and no `SyncEngine` dependency, so it's fully unit-testable in isolation:
|
||||
// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the
|
||||
// referenced-split-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
|
||||
// referenced-routine-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
|
||||
// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file.
|
||||
//
|
||||
// Duplicates are detected by a *content* fingerprint that deliberately ignores
|
||||
// identity (ids), timestamps, and cosmetic fields — two splits/workouts that were
|
||||
// identity (ids), timestamps, and cosmetic fields — two routines/workouts that were
|
||||
// created independently (e.g. by a sync hiccup, a restore, or manual testing) but
|
||||
// carry the same real content are duplicates even though every id differs.
|
||||
|
||||
// MARK: - Plan
|
||||
|
||||
struct DuplicateCleanupPlan: Sendable {
|
||||
struct SplitGroup: Sendable, Identifiable {
|
||||
struct RoutineGroup: Sendable, Identifiable {
|
||||
/// Members that survive — always non-empty when the group is emitted.
|
||||
var keep: [SplitDocument]
|
||||
var keep: [RoutineDocument]
|
||||
/// Members slated for deletion — always non-empty when the group is emitted.
|
||||
var delete: [SplitDocument]
|
||||
var delete: [RoutineDocument]
|
||||
|
||||
var id: String {
|
||||
keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
|
||||
@@ -32,14 +32,14 @@ struct DuplicateCleanupPlan: Sendable {
|
||||
var id: String { keep.id }
|
||||
}
|
||||
|
||||
var splitGroups: [SplitGroup]
|
||||
var routineGroups: [RoutineGroup]
|
||||
var workoutGroups: [WorkoutGroup]
|
||||
|
||||
var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty }
|
||||
var isEmpty: Bool { routineGroups.isEmpty && workoutGroups.isEmpty }
|
||||
|
||||
/// Total number of documents this plan would delete across every group.
|
||||
var deleteCount: Int {
|
||||
splitGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
|
||||
routineGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,22 +47,23 @@ struct DuplicateCleanupPlan: Sendable {
|
||||
|
||||
enum DuplicateCleanupPlanner {
|
||||
/// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered
|
||||
/// documents. `referencedSplitIDs` must include every split id any workout
|
||||
/// (readable or quarantined) points at, plus any clone-redirect targets — the
|
||||
/// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it.
|
||||
/// documents. `referencedRoutineIDs` must include every routine id any workout
|
||||
/// or schedule (readable or quarantined) points at, plus any clone-redirect
|
||||
/// targets — the caller (`SyncEngine.scanForDuplicates`) is responsible for
|
||||
/// assembling it.
|
||||
static func plan(
|
||||
splits: [SplitDocument],
|
||||
routines: [RoutineDocument],
|
||||
workouts: [WorkoutDocument],
|
||||
referencedSplitIDs: Set<String>,
|
||||
referencedRoutineIDs: Set<String>,
|
||||
isSeed: (String) -> Bool
|
||||
) -> DuplicateCleanupPlan {
|
||||
DuplicateCleanupPlan(
|
||||
splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed),
|
||||
routineGroups: planRoutines(routines, referencedRoutineIDs: referencedRoutineIDs, isSeed: isSeed),
|
||||
workoutGroups: planWorkouts(workouts)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Splits
|
||||
// MARK: Routines
|
||||
|
||||
/// Hashable projection of a `MachineSetting`. The fingerprints keep the
|
||||
/// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
|
||||
@@ -88,15 +89,15 @@ enum DuplicateCleanupPlanner {
|
||||
var machineSettings: [MachineSettingFingerprint]?
|
||||
}
|
||||
|
||||
/// Content-only projection of a split: ignores id, dates, color, systemImage,
|
||||
/// Content-only projection of a routine: ignores id, dates, color, systemImage,
|
||||
/// order, and activityType.
|
||||
private struct SplitFingerprint: Hashable {
|
||||
private struct RoutineFingerprint: Hashable {
|
||||
var name: String
|
||||
var exercises: [ExerciseFingerprint]
|
||||
}
|
||||
|
||||
private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint {
|
||||
SplitFingerprint(
|
||||
private static func fingerprint(_ doc: RoutineDocument) -> RoutineFingerprint {
|
||||
RoutineFingerprint(
|
||||
name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
exercises: doc.exercises.sorted { $0.order < $1.order }.map {
|
||||
ExerciseFingerprint(
|
||||
@@ -115,19 +116,19 @@ enum DuplicateCleanupPlanner {
|
||||
/// (ULIDs sort chronologically) survives — deterministic across devices.
|
||||
/// Everyone else in the group is deleted. A group with nothing to delete
|
||||
/// (e.g. every member is protected) is dropped entirely.
|
||||
private static func planSplits(
|
||||
_ splits: [SplitDocument],
|
||||
referencedSplitIDs: Set<String>,
|
||||
private static func planRoutines(
|
||||
_ routines: [RoutineDocument],
|
||||
referencedRoutineIDs: Set<String>,
|
||||
isSeed: (String) -> Bool
|
||||
) -> [DuplicateCleanupPlan.SplitGroup] {
|
||||
let grouped = Dictionary(grouping: splits, by: fingerprint)
|
||||
var groups: [DuplicateCleanupPlan.SplitGroup] = []
|
||||
) -> [DuplicateCleanupPlan.RoutineGroup] {
|
||||
let grouped = Dictionary(grouping: routines, by: fingerprint)
|
||||
var groups: [DuplicateCleanupPlan.RoutineGroup] = []
|
||||
|
||||
for (_, members) in grouped where members.count > 1 {
|
||||
var keep: [SplitDocument] = []
|
||||
var candidates: [SplitDocument] = []
|
||||
var keep: [RoutineDocument] = []
|
||||
var candidates: [RoutineDocument] = []
|
||||
for member in members {
|
||||
if referencedSplitIDs.contains(member.id) || isSeed(member.id) {
|
||||
if referencedRoutineIDs.contains(member.id) || isSeed(member.id) {
|
||||
keep.append(member)
|
||||
} else {
|
||||
candidates.append(member)
|
||||
@@ -138,7 +139,7 @@ enum DuplicateCleanupPlanner {
|
||||
candidates.removeAll { $0.id == survivorID }
|
||||
}
|
||||
guard !candidates.isEmpty else { continue }
|
||||
groups.append(DuplicateCleanupPlan.SplitGroup(
|
||||
groups.append(DuplicateCleanupPlan.RoutineGroup(
|
||||
keep: keep.sorted { $0.id < $1.id },
|
||||
delete: candidates.sorted { $0.id < $1.id }
|
||||
))
|
||||
@@ -169,8 +170,8 @@ enum DuplicateCleanupPlanner {
|
||||
/// startedAt/completedAt, exact start/end time (only the calendar day of
|
||||
/// `start` matters), and metrics.
|
||||
private struct WorkoutFingerprint: Hashable {
|
||||
var splitID: String
|
||||
var splitName: String
|
||||
var routineID: String
|
||||
var routineName: String
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
@@ -181,8 +182,8 @@ enum DuplicateCleanupPlanner {
|
||||
private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint {
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start)
|
||||
return WorkoutFingerprint(
|
||||
splitID: doc.splitID ?? "",
|
||||
splitName: doc.splitName ?? "",
|
||||
routineID: doc.routineID ?? "",
|
||||
routineName: doc.routineName ?? "",
|
||||
year: comps.year ?? 0,
|
||||
month: comps.month ?? 0,
|
||||
day: comps.day ?? 0,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
// Pure decision logic for bringing an install's on-disk starter-split library in
|
||||
// Pure decision logic for bringing an install's on-disk starter-routine library in
|
||||
// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the
|
||||
// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()`
|
||||
// gathers the live seed-path files, stub set, and live split names, hands each seed to
|
||||
// gathers the live seed-path files, stub set, and live routine names, hands each seed to
|
||||
// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write.
|
||||
//
|
||||
// Why this exists: starter seeds ship as byte-canonical `SplitDocument` JSON under
|
||||
// Why this exists: starter seeds ship as byte-canonical `RoutineDocument` JSON under
|
||||
// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a
|
||||
// verifiably empty container, so an existing install can never pick up an upgraded
|
||||
// seed revision or a newly-shipped seed. This planner decides, per seed, whether the
|
||||
@@ -26,10 +26,10 @@ struct SeedReconcileInput: Equatable {
|
||||
/// The seed's fixed ULID.
|
||||
let seedID: String
|
||||
/// The bundle seed document (canonical current content).
|
||||
let seedDoc: SplitDocument
|
||||
let seedDoc: RoutineDocument
|
||||
/// The decoded live file currently sitting at the seed's fixed path, or `nil`
|
||||
/// when no live file exists there (deleted, or never written).
|
||||
let liveDoc: SplitDocument?
|
||||
let liveDoc: RoutineDocument?
|
||||
/// Whether a tombstone stub exists for this seed's id.
|
||||
let hasStub: Bool
|
||||
}
|
||||
@@ -54,7 +54,7 @@ enum SeedSkipReason: Equatable {
|
||||
case quarantined
|
||||
/// No live file, but a stub exists: the user deleted this seed. Respect the veto.
|
||||
case vetoed
|
||||
/// No live file and no stub, but a live split already uses this seed's name
|
||||
/// No live file and no stub, but a live routine already uses this seed's name
|
||||
/// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed
|
||||
/// would duplicate it by name.
|
||||
case nameCollision
|
||||
@@ -64,11 +64,11 @@ enum SeedSkipReason: Equatable {
|
||||
|
||||
enum SeedReconcilePlanner {
|
||||
/// The automatic on-connect decision for one seed (see the decision table in the
|
||||
/// file header). `liveSplitNames` must be the names of every live split currently
|
||||
/// file header). `liveRoutineNames` must be the names of every live routine currently
|
||||
/// in the container/cache after reconcile — the name-collision guard consults it.
|
||||
static func decision(
|
||||
for input: SeedReconcileInput,
|
||||
liveSplitNames: Set<String>
|
||||
liveRoutineNames: Set<String>
|
||||
) -> SeedReconcileDecision {
|
||||
if let live = input.liveDoc {
|
||||
// A live file sits at the seed path. Never touch one a newer app version
|
||||
@@ -80,7 +80,7 @@ enum SeedReconcilePlanner {
|
||||
}
|
||||
// No live file at the seed path.
|
||||
if input.hasStub { return .skip(.vetoed) }
|
||||
if liveSplitNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
|
||||
if liveRoutineNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
|
||||
return .write
|
||||
}
|
||||
|
||||
@@ -88,27 +88,27 @@ enum SeedReconcilePlanner {
|
||||
///
|
||||
/// A byte compare is wrong here: a container file may have been written by an
|
||||
/// older app version, carrying extra now-removed keys and a lower `schemaVersion`.
|
||||
/// Decoding both into the current `SplitDocument` already drops unknown keys, so
|
||||
/// Decoding both into the current `RoutineDocument` already drops unknown keys, so
|
||||
/// the only remaining artificial difference is the version stamp — normalize the
|
||||
/// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
|
||||
/// identical content reads as up to date (no churn), while a genuine content
|
||||
/// change reads as different (upgrade).
|
||||
static func semanticallyEqual(live: SplitDocument, seed: SplitDocument) -> Bool {
|
||||
static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool {
|
||||
var normalized = live
|
||||
normalized.schemaVersion = seed.schemaVersion
|
||||
return normalized == seed
|
||||
}
|
||||
|
||||
/// Whether the deliberate "Restore Starter Splits" action should (re)write a seed:
|
||||
/// only when no live file sits at its path and no live split already uses its name.
|
||||
/// Whether the deliberate "Restore Starter Routines" action should (re)write a seed:
|
||||
/// only when no live file sits at its path and no live routine already uses its name.
|
||||
/// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but
|
||||
/// the engine still removes the stub as a separate execution step. A seed whose
|
||||
/// live file already exists is left for `decision(...)` to upgrade.
|
||||
static func shouldRestore(
|
||||
hasLiveFile: Bool,
|
||||
seedName: String,
|
||||
liveSplitNames: Set<String>
|
||||
liveRoutineNames: Set<String>
|
||||
) -> Bool {
|
||||
!hasLiveFile && !liveSplitNames.contains(seedName)
|
||||
!hasLiveFile && !liveRoutineNames.contains(seedName)
|
||||
}
|
||||
}
|
||||
|
||||
+180
-93
@@ -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 Split→Routine 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 Split→Routine 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
|
||||
|
||||
@@ -64,7 +64,7 @@ enum WorkoutMergePlanner {
|
||||
// 4. Prune tombstones past the grace period.
|
||||
tombstones = tombstones.filter { now.timeIntervalSince($0.value) < tombstoneGrace }
|
||||
|
||||
// 5. Base scalars (splitID/name, start, metrics, …) on the newer document overall — it
|
||||
// 5. Base scalars (routineID/name, start, metrics, …) on the newer document overall — it
|
||||
// carries a just-attached metrics summary or a rename — then override the reconciled
|
||||
// parts. Ties prefer the cached copy (the phone is the sole writer). Sort is
|
||||
// id-tie-broken so the result is deterministic even when two logs share an `order`.
|
||||
|
||||
@@ -13,9 +13,21 @@ import Foundation
|
||||
/// supersede checks key on it, newer wins.
|
||||
struct PendingWrite: Codable, Sendable {
|
||||
enum Payload: Codable, Sendable {
|
||||
case split(SplitDocument)
|
||||
case routine(RoutineDocument)
|
||||
case workout(WorkoutDocument)
|
||||
case schedule(ScheduleDocument)
|
||||
case delete(id: String, kind: String, livePath: String)
|
||||
|
||||
// PINNED KEY: the case was renamed `.split` → `.routine`, but its persisted
|
||||
// JSON key stays `"split"` so a `PendingWrites.json` sidecar written by a
|
||||
// previous run still decodes. `workout` / `schedule` / `delete` keep their
|
||||
// default keys.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case routine = "split"
|
||||
case workout
|
||||
case schedule
|
||||
case delete
|
||||
}
|
||||
}
|
||||
|
||||
var payload: Payload
|
||||
@@ -36,8 +48,9 @@ struct PendingWrite: Codable, Sendable {
|
||||
|
||||
var documentID: String {
|
||||
switch payload {
|
||||
case .split(let doc): doc.id
|
||||
case .routine(let doc): doc.id
|
||||
case .workout(let doc): doc.id
|
||||
case .schedule(let doc): doc.id
|
||||
case .delete(let id, _, _): id
|
||||
}
|
||||
}
|
||||
@@ -45,8 +58,9 @@ struct PendingWrite: Codable, Sendable {
|
||||
/// Where a successful write lands; nil for deletes (they only remove).
|
||||
var targetPath: String? {
|
||||
switch payload {
|
||||
case .split(let doc): doc.relativePath
|
||||
case .routine(let doc): doc.relativePath
|
||||
case .workout(let doc): doc.relativePath
|
||||
case .schedule(let doc): doc.relativePath
|
||||
case .delete: nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user