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
+12 -7
View File
@@ -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
}
}