// // 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 = [] 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) } } }