diff --git a/Workouts/Sync/ScheduleRepair.swift b/Workouts/Sync/ScheduleRepair.swift new file mode 100644 index 0000000..fada271 --- /dev/null +++ b/Workouts/Sync/ScheduleRepair.swift @@ -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 = [] + 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) + } + } +} diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index ea75764..e730634 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -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(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())) ?? [] + let routines = (try? context.fetch(FetchDescriptor())) ?? [] + + 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 diff --git a/WorkoutsTests/ScheduleRepairPlannerTests.swift b/WorkoutsTests/ScheduleRepairPlannerTests.swift new file mode 100644 index 0000000..9788ba7 --- /dev/null +++ b/WorkoutsTests/ScheduleRepairPlannerTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import Workouts + +/// Locks `ScheduleRepairPlanner`'s pure decision rules — schedule/routine cache +/// snapshots in, [(scheduleID, newRoutineID)] out. The engine's apply pass (file +/// rewrite + cache upsert + single notify) is deliberately untested here; the +/// planner carries all the judgment. +struct ScheduleRepairPlannerTests { + + private func schedule(id: String = "S1", routineID: String, routineName: String) -> ScheduleRepairRef { + ScheduleRepairRef(id: id, routineID: routineID, routineName: routineName) + } + + private func routine(id: String, name: String) -> RoutineRepairRef { + RoutineRepairRef(id: id, name: name) + } + + // MARK: - Repairs + + @Test func brokenReferenceWithUniqueNameMatchIsRepaired() { + let repairs = ScheduleRepairPlanner.plans( + schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")], + liveRoutines: [routine(id: "CLONE-1", name: "Cardio"), + routine(id: "R-2", name: "Upper Body")]) + + #expect(repairs == [ScheduleRepair(scheduleID: "S1", newRoutineID: "CLONE-1")]) + } + + @Test func multipleBrokenSchedulesEachRepairToTheirOwnNameMatch() { + let repairs = ScheduleRepairPlanner.plans( + schedules: [ + schedule(id: "S1", routineID: "DEAD-A", routineName: "Cardio"), + schedule(id: "S2", routineID: "DEAD-B", routineName: "Upper Body"), + ], + liveRoutines: [routine(id: "R-1", name: "Cardio"), + routine(id: "R-2", name: "Upper Body")]) + + #expect(repairs == [ + ScheduleRepair(scheduleID: "S1", newRoutineID: "R-1"), + ScheduleRepair(scheduleID: "S2", newRoutineID: "R-2"), + ]) + } + + // MARK: - Left alone + + @Test func brokenReferenceWithNoNameMatchIsUntouched() { + let repairs = ScheduleRepairPlanner.plans( + schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")], + liveRoutines: [routine(id: "R-2", name: "Upper Body")]) + + #expect(repairs.isEmpty) + } + + @Test func brokenReferenceWithTwoSameNameLiveRoutinesIsUntouched() { + // Two live routines both named "Cardio" — the match would be a guess, so the + // schedule is left for the edit form's "no longer available" flow. + let repairs = ScheduleRepairPlanner.plans( + schedules: [schedule(routineID: "DEAD-SEED", routineName: "Cardio")], + liveRoutines: [routine(id: "R-1", name: "Cardio"), + routine(id: "R-2", name: "Cardio")]) + + #expect(repairs.isEmpty) + } + + @Test func healthyReferenceIsUntouchedEvenWhenAnotherRoutineSharesTheName() { + // The schedule's routineID resolves fine — a same-name routine elsewhere must + // not lure it away. + let repairs = ScheduleRepairPlanner.plans( + schedules: [schedule(routineID: "R-1", routineName: "Cardio")], + liveRoutines: [routine(id: "R-1", name: "Cardio"), + routine(id: "R-9", name: "Cardio")]) + + #expect(repairs.isEmpty) + } + + // MARK: - Degenerate inputs + + @Test func emptyInputsProduceNoRepairs() { + #expect(ScheduleRepairPlanner.plans(schedules: [], liveRoutines: []).isEmpty) + #expect(ScheduleRepairPlanner.plans( + schedules: [], liveRoutines: [routine(id: "R-1", name: "Cardio")]).isEmpty) + // Broken schedule, no live routines at all → nothing to repair to. + #expect(ScheduleRepairPlanner.plans( + schedules: [schedule(routineID: "DEAD", routineName: "Cardio")], + liveRoutines: []).isEmpty) + } +}