Treat order as presentation in seed logic; add per-seed restore and routine duplication

Drag-to-reorder writes Routine.order through the document, which used to
read as a content edit: isPristine would fork a starter for a mere reorder,
and reconcile's semantic compare would clobber a reordered seed file back
to bundle order. Both now normalize order (and updatedAt) away — a fixed-
ULID file still never holds user content; ordering is bookkeeping.

SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk
restore, sharing one restoreSeedIfEligible core — and duplicate(routine:),
which copies any routine (starter or not) to a fresh ULID with fresh
exercise ids, a unique "… Copy" name, and last position in the list.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-15 10:45:02 -04:00
parent 36dad21233
commit 16b61946d5
5 changed files with 178 additions and 28 deletions
+30
View File
@@ -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 {