diff --git a/Workouts/Seed/SeedLibrary.swift b/Workouts/Seed/SeedLibrary.swift index 194e13d..c4d632c 100644 --- a/Workouts/Seed/SeedLibrary.swift +++ b/Workouts/Seed/SeedLibrary.swift @@ -52,18 +52,23 @@ enum SeedLibrary { static func seed(id: String) -> Seed? { seeds.first { $0.id == id } } - /// True when `doc` is unchanged from its pristine seed in every field except - /// `updatedAt`. The edit sheets stamp `updatedAt` on every Save, so a no-op save - /// would otherwise read as an edit and fork the seed; normalizing both timestamps - /// to a common value before the `==` isolates real changes. A non-seed id has no + /// True when `doc` is unchanged from its pristine seed in every field that counts + /// as *content* — i.e. ignoring `updatedAt` and `order`, both of which are + /// presentation/bookkeeping rather than content. The edit sheets stamp `updatedAt` + /// on every Save, so a no-op save would otherwise read as an edit and fork the seed; + /// and dragging a starter to reorder the list writes a new `order` to it — a reorder + /// must NOT fork a curated starter into a user routine. Normalizing both fields to a + /// common value before the `==` isolates real content changes. A non-seed id has no /// seed to compare against — treated as pristine (callers gate on `isSeed` first). static func isPristine(_ doc: RoutineDocument) -> Bool { guard let seed = seed(id: doc.id) else { return true } var edited = doc var pristine = seed.doc - let common = Date(timeIntervalSince1970: 0) - edited.updatedAt = common - pristine.updatedAt = common + let commonDate = Date(timeIntervalSince1970: 0) + edited.updatedAt = commonDate + pristine.updatedAt = commonDate + edited.order = 0 + pristine.order = 0 return edited == pristine } } diff --git a/Workouts/Sync/SeedReconcile.swift b/Workouts/Sync/SeedReconcile.swift index ec864eb..b931a72 100644 --- a/Workouts/Sync/SeedReconcile.swift +++ b/Workouts/Sync/SeedReconcile.swift @@ -93,9 +93,19 @@ enum SeedReconcilePlanner { /// 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). + /// + /// `order` and `updatedAt` are likewise normalized away: dragging a starter to + /// reorder the Library list writes a new `order` (and stamps `updatedAt`) onto the + /// seed's fixed-ULID file. That is presentation, not content — a live seed whose + /// only drift is the user's ordering must read `.upToDate` so the on-connect + /// reconcile never clobbers it back to the bundle's default order. A genuine content + /// change still differs and upgrades. (Accepted tradeoff: a real bundle-shipped seed + /// revision does upgrade verbatim and resets that one seed's order — rare, harmless.) static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool { var normalized = live normalized.schemaVersion = seed.schemaVersion + normalized.order = seed.order + normalized.updatedAt = seed.updatedAt return normalized == seed } diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index e730634..d701d1e 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -395,6 +395,40 @@ final class SyncEngine { } } + /// Duplicate any routine — user-authored or a starter — into a fresh, fully + /// independent user routine. Unlike clone-on-edit (which forks a *seed* under the + /// covers and preserves its identity), a duplicate of a starter is deliberately a + /// plain user routine: the copy gets a brand-new random ULID, so it is NOT a seed — + /// no starter badge, and editing it never triggers clone-on-edit. Every exercise also + /// gets a fresh ULID (mandatory: `Exercise.id` is `@Attribute(.unique)` and both + /// routines stay live, so shared ids would violate the constraint), with order and + /// content otherwise preserved. The copy is named with the next free "… Copy" variant + /// and appended to the end of the routine list. Returns the new routine's id, or nil + /// when the engine isn't connected. + @discardableResult + func duplicate(routine: Routine) async -> String? { + guard store != nil else { return nil } + + var doc = RoutineDocument(from: routine) + doc.id = ULID.make() + let now = Date() + doc.createdAt = now + doc.updatedAt = now + // Fresh id per exercise — both the source and the copy stay live, and + // `Exercise.id` is unique-constrained, so the copy can't reuse the originals. + for i in doc.exercises.indices { + doc.exercises[i].id = ULID.make() + } + + // Append after the current last routine, and pick a name unique among live ones. + let liveRoutines = (try? context.fetch(FetchDescriptor())) ?? [] + doc.order = (liveRoutines.map(\.order).max() ?? -1) + 1 + doc.name = RoutineNaming.uniqueName(base: routine.name, existing: Set(liveRoutines.map(\.name))) + + // A fresh ULID means this is not a seed, so `save` takes the plain path (no fork). + return await save(routine: doc) + } + /// 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, @@ -1052,32 +1086,13 @@ final class SyncEngine { /// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written. @discardableResult func restoreSeeds() async -> Int { - guard let store, let tombstones else { return 0 } + guard let store, tombstones != nil else { return 0 } let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") }) var restored = 0 for seed in SeedLibrary.seeds { - let liveNames = Set(((try? context.fetch(FetchDescriptor())) ?? []).map(\.name)) - guard SeedReconcilePlanner.shouldRestore( - hasLiveFile: livePaths.contains(seed.doc.relativePath), - seedName: seed.doc.name, - liveRoutineNames: liveNames - ) else { continue } - - do { - // Lift the veto: drop the seed's tombstone stub if one exists, then - // write the verbatim bundle bytes. - if await tombstones.stubExists(id: seed.id) { - try await tombstones.removeStub(at: "\(seed.id).json") - } - try await store.writeData(seed.data, to: seed.doc.relativePath) - CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context) - restored += 1 - lastSyncError = nil - } catch { - report("Failed to restore starter routine \(seed.doc.name)", error) - } + if await restoreSeedIfEligible(seed, livePaths: livePaths) { restored += 1 } } if restored > 0 { @@ -1087,6 +1102,58 @@ final class SyncEngine { return restored } + /// The per-seed analog of `restoreSeeds()` — restore ONE starter routine by its fixed + /// seed id (used by the Library tab's per-row restore). Runs the identical gate and + /// writes as the bulk path via the shared `restoreSeedIfEligible` core: a no-op unless + /// that seed is both absent and free of a live same-name routine, in which case it + /// lifts the veto stub and writes the current bundle bytes. Returns whether it wrote — + /// false for an unknown id, a not-connected engine, or a seed deliberately left alone. + func restoreSeed(id: String) async -> Bool { + guard let store, tombstones != nil else { return false } + guard let seed = SeedLibrary.seed(id: id) else { return false } + + let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") }) + let wrote = await restoreSeedIfEligible(seed, livePaths: livePaths) + + if wrote { + do { try context.save() } catch { report("Cache save failed", error) } + onCacheChanged?() + } + return wrote + } + + /// Shared per-seed restore core for `restoreSeeds()` (bulk) and `restoreSeed(id:)` + /// (single). Gates on `SeedReconcilePlanner.shouldRestore` — restore only a seed that + /// is absent and whose name is free — then lifts its veto stub (if any) and writes the + /// verbatim bundle bytes, mirroring the cache entity. Live routine names are re-read + /// here (not passed in) so a prior write in the bulk loop is seen. Returns whether it + /// wrote; the caller owns the single `context.save()` + `onCacheChanged` fan-out. + private func restoreSeedIfEligible(_ seed: SeedLibrary.Seed, livePaths: Set) async -> Bool { + guard let store, let tombstones else { return false } + + let liveNames = Set(((try? context.fetch(FetchDescriptor())) ?? []).map(\.name)) + guard SeedReconcilePlanner.shouldRestore( + hasLiveFile: livePaths.contains(seed.doc.relativePath), + seedName: seed.doc.name, + liveRoutineNames: liveNames + ) else { return false } + + do { + // Lift the veto: drop the seed's tombstone stub if one exists, then write + // the verbatim bundle bytes. + if await tombstones.stubExists(id: seed.id) { + try await tombstones.removeStub(at: "\(seed.id).json") + } + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context) + lastSyncError = nil + return true + } catch { + report("Failed to restore starter routine \(seed.doc.name)", error) + return false + } + } + /// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns /// whether the write succeeded (so the caller can flush + notify once at the end). @discardableResult diff --git a/WorkoutsTests/SeedLibraryTests.swift b/WorkoutsTests/SeedLibraryTests.swift index 9eeec77..34a3745 100644 --- a/WorkoutsTests/SeedLibraryTests.swift +++ b/WorkoutsTests/SeedLibraryTests.swift @@ -93,6 +93,36 @@ struct SeedLibraryTests { #expect(!SeedLibrary.isPristine(edited)) } + /// Dragging a starter to reorder the Library list writes a new `order` to it. Order + /// is presentation, not content, so a reorder must NOT fork the seed — an order-only + /// change reads as pristine. + @Test func pristineIgnoresOrderOnlyChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.order += 5 + #expect(SeedLibrary.isPristine(edited)) + } + + /// A reorder save also stamps `updatedAt`; order + updatedAt together must still read + /// as pristine so a drag never forks a starter. + @Test func pristineIgnoresOrderAndUpdatedAtChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.order += 3 + edited.updatedAt = Date() + #expect(SeedLibrary.isPristine(edited)) + } + + /// Order tolerance must not mask a genuine content edit: a real change (here, a + /// different rest length) is still detected even when `order` also differs. + @Test func pristineDetectsContentChangeDespiteOrderChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.order += 2 + edited.restSeconds = (seed.doc.restSeconds ?? 60) + 30 + #expect(!SeedLibrary.isPristine(edited)) + } + /// A non-seed id has no seed to compare against; `isPristine` defaults to /// true since callers are expected to gate on `isSeed` before asking. @Test func pristineDefaultsTrueForNonSeedID() throws { diff --git a/WorkoutsTests/SeedReconcilePlannerTests.swift b/WorkoutsTests/SeedReconcilePlannerTests.swift index 55749dd..b9e8e12 100644 --- a/WorkoutsTests/SeedReconcilePlannerTests.swift +++ b/WorkoutsTests/SeedReconcilePlannerTests.swift @@ -85,6 +85,44 @@ struct SeedReconcilePlannerTests { #expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.quarantined)) } + // MARK: - Order tolerance (user reordered their starters) + + /// The user dragged this starter to a new position, writing a new `order` to its + /// fixed-ULID file. That is presentation, not content, so reconcile must read it as + /// up to date and never clobber the ordering back to the bundle's default. + @Test func orderOnlyDriftIsUpToDate() { + let bundle = seed() // order 0 + var live = bundle + live.order = 7 // user reordered their starters + let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) + + #expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate)) + } + + /// A reorder save also stamps `updatedAt`; order + updatedAt drift together must + /// still read as up to date. + @Test func orderAndUpdatedAtDriftIsUpToDate() { + let bundle = seed() + var live = bundle + live.order = 4 + live.updatedAt = Date() // a reorder save bumps updatedAt too + let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) + + #expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate)) + } + + /// Order tolerance must not mask a genuine content upgrade: when the bundle content + /// changed AND the user had reordered (order differs too), it still upgrades — the + /// order normalization can't swallow the real content diff. + @Test func contentUpgradeWinsOverOrderTolerance() { + let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)]) + var live = seed(exercises: [exercise(name: "Bench Press")]) // bundle gained an exercise + live.order = 9 // and the user had reordered + let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false) + + #expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .upgrade) + } + // MARK: - No live file /// No live file but a stub exists → the user deleted this seed. The veto stands.