Files
workouts/Workouts/Sync/ScheduleRepair.swift
T
rzen e509d0e7c5 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
2026-07-11 13:02:55 -04:00

79 lines
3.0 KiB
Swift

//
// 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)
}
}
}