Keep schedules attached to routines across seed clone-on-edit forks
Editing a starter-seed routine forks it to a fresh ULID, but only workouts were durably repointed — schedules kept the dead seed id, and after a relaunch (empty in-memory redirect map) the Today board and edit form saw the routine as gone. - cloneSeedOnEdit now repoints schedules too (routineID + routineName track the clone; a schedule's name is a live-pointer fallback, unlike a workout's frozen run-as name) - A launch-time repair pass (pure ScheduleRepairPlanner + tests) heals already-broken references by re-attaching a dead-pointer schedule to the unique live routine matching its remembered name
This commit is contained in:
@@ -386,6 +386,7 @@ final class SyncEngine {
|
||||
cloneRedirects[doc.id] = clone.id
|
||||
lastSyncError = nil
|
||||
await repointWorkouts(from: doc.id, to: clone.id)
|
||||
await repointSchedules(from: doc.id, to: clone.id, newName: clone.name)
|
||||
onCacheChanged?()
|
||||
return clone.id
|
||||
} catch {
|
||||
@@ -421,6 +422,34 @@ final class SyncEngine {
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
}
|
||||
|
||||
/// Rewrite `routineID` on every schedule that references `oldID`, so the plan layer
|
||||
/// (the Today board, edit form, adherence tracks) keeps resolving after a seed fork —
|
||||
/// durably, across relaunches, which the in-memory `cloneRedirects` map can't cover.
|
||||
/// Unlike a workout's frozen "what it was run as" `routineName`, a schedule's
|
||||
/// `routineName` is a display *fallback* for a live pointer, so it tracks the clone's
|
||||
/// current name. Best effort per schedule: a failed rewrite is reported and the
|
||||
/// redirect map still covers it for this session.
|
||||
private func repointSchedules(from oldID: String, to newID: String, newName: String) async {
|
||||
guard let store else { return }
|
||||
let referencing = (try? context.fetch(
|
||||
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == oldID })
|
||||
)) ?? []
|
||||
guard !referencing.isEmpty else { return }
|
||||
for schedule in referencing {
|
||||
var sDoc = ScheduleDocument(from: schedule)
|
||||
sDoc.routineID = newID
|
||||
sDoc.routineName = newName
|
||||
sDoc.updatedAt = Date()
|
||||
do {
|
||||
try await store.write(sDoc, to: sDoc.relativePath)
|
||||
CacheMapper.upsertSchedule(sDoc, relativePath: sDoc.relativePath, into: context)
|
||||
} catch {
|
||||
report("Failed to repoint schedule \(sDoc.id) at edited routine", error)
|
||||
}
|
||||
}
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -898,6 +927,50 @@ final class SyncEngine {
|
||||
} else {
|
||||
await reconcileSeeds()
|
||||
}
|
||||
// After the seed set has settled, heal schedules whose routine reference died
|
||||
// before fork-time repointing existed (cheap, idempotent — every launch).
|
||||
await repairScheduleReferences()
|
||||
}
|
||||
|
||||
/// Repair schedules whose `routineID` matches no live routine, re-attaching each to
|
||||
/// the unique live routine carrying the schedule's `routineName` (the pure
|
||||
/// `ScheduleRepairPlanner` decides; ambiguous or unmatched ones are left alone).
|
||||
/// Batched: one cache save + one change notification when anything was repaired —
|
||||
/// deliberately NOT routed through `save(schedule:)`, which would fire the watch
|
||||
/// push and reminder resync once per schedule.
|
||||
private func repairScheduleReferences() async {
|
||||
guard let store else { return }
|
||||
let schedules = (try? context.fetch(FetchDescriptor<Schedule>())) ?? []
|
||||
let routines = (try? context.fetch(FetchDescriptor<Routine>())) ?? []
|
||||
|
||||
let repairs = ScheduleRepairPlanner.plans(
|
||||
schedules: schedules.map {
|
||||
ScheduleRepairRef(id: $0.id, routineID: $0.routineID, routineName: $0.routineName)
|
||||
},
|
||||
liveRoutines: routines.map { RoutineRepairRef(id: $0.id, name: $0.name) }
|
||||
)
|
||||
guard !repairs.isEmpty else { return }
|
||||
|
||||
var repaired = 0
|
||||
for repair in repairs {
|
||||
guard let schedule = CacheMapper.fetchSchedule(id: repair.scheduleID, in: context) else { continue }
|
||||
var doc = ScheduleDocument(from: schedule)
|
||||
doc.routineID = repair.newRoutineID
|
||||
doc.updatedAt = Date()
|
||||
do {
|
||||
try await store.write(doc, to: doc.relativePath)
|
||||
CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context)
|
||||
repaired += 1
|
||||
} catch {
|
||||
report("Failed to repair schedule \(doc.id) routine reference", error)
|
||||
}
|
||||
}
|
||||
|
||||
if repaired > 0 {
|
||||
log.info("schedule repair: re-attached \(repaired) schedule(s) to live routines")
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
onCacheChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the on-disk seed set in line with the current bundle on a NON-empty
|
||||
|
||||
Reference in New Issue
Block a user