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:
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// ScheduleRepair.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Pure decision logic for repairing schedules whose `routineID` no longer resolves
|
||||
// to any live routine (precedent: `SeedReconcilePlanner` / `ReminderPlanner`). No I/O
|
||||
// and no `SyncEngine` dependency: the engine snapshots the schedule and routine
|
||||
// caches, and applies the returned repairs.
|
||||
//
|
||||
// Why this exists: before schedules were repointed at seed-fork time, editing a
|
||||
// starter-seed routine soft-deleted the seed and left every schedule pointing at the
|
||||
// dead fixed ULID — permanently, since the in-memory `cloneRedirects` map is empty
|
||||
// after a relaunch. Fork-time repointing fixes new forks; this repair pass heals the
|
||||
// references existing user data already broke. It's cheap and idempotent, so it runs
|
||||
// on every launch after the cache is reconciled.
|
||||
|
||||
// MARK: - Snapshots
|
||||
|
||||
/// A plain (id, routineID, routineName) snapshot of a `Schedule` cache entity.
|
||||
struct ScheduleRepairRef: Sendable, Equatable {
|
||||
var id: String
|
||||
var routineID: String
|
||||
var routineName: String
|
||||
}
|
||||
|
||||
/// A plain (id, name) snapshot of a live `Routine` cache entity.
|
||||
struct RoutineRepairRef: Sendable, Equatable {
|
||||
var id: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
/// One repair to apply: rewrite this schedule's `routineID` to the matched routine.
|
||||
struct ScheduleRepair: Sendable, Equatable {
|
||||
var scheduleID: String
|
||||
var newRoutineID: String
|
||||
}
|
||||
|
||||
// MARK: - Planner
|
||||
|
||||
enum ScheduleRepairPlanner {
|
||||
/// The repairs for the given caches. A schedule needs repair when its `routineID`
|
||||
/// matches no live routine; the repair target is the live routine whose name equals
|
||||
/// the schedule's denormalized `routineName` — but **only when exactly one** live
|
||||
/// routine carries that name. Zero matches (routine truly gone) or several (the
|
||||
/// match would be a guess) leave the schedule alone — the edit form's
|
||||
/// "no longer available" flow is the deliberate fallback there.
|
||||
static func plans(
|
||||
schedules: [ScheduleRepairRef],
|
||||
liveRoutines: [RoutineRepairRef]
|
||||
) -> [ScheduleRepair] {
|
||||
let liveIDs = Set(liveRoutines.map(\.id))
|
||||
|
||||
// Routine name → its sole live id, with names carried by several routines
|
||||
// tracked separately (an ambiguous name must never be a repair target).
|
||||
var idByName: [String: String] = [:]
|
||||
var ambiguousNames: Set<String> = []
|
||||
for routine in liveRoutines {
|
||||
if idByName[routine.name] != nil {
|
||||
ambiguousNames.insert(routine.name)
|
||||
} else {
|
||||
idByName[routine.name] = routine.id
|
||||
}
|
||||
}
|
||||
|
||||
return schedules.compactMap { schedule in
|
||||
guard !liveIDs.contains(schedule.routineID),
|
||||
!ambiguousNames.contains(schedule.routineName),
|
||||
let newID = idByName[schedule.routineName]
|
||||
else { return nil }
|
||||
return ScheduleRepair(scheduleID: schedule.id, newRoutineID: newID)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user