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