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
79 lines
3.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|