Files
workouts/Workouts/Sync/DuplicateCleanup.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

237 lines
9.6 KiB
Swift

import Foundation
// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O
// and no `SyncEngine` dependency, so it's fully unit-testable in isolation:
// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the
// referenced-routine-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file.
//
// Duplicates are detected by a *content* fingerprint that deliberately ignores
// identity (ids), timestamps, and cosmetic fields — two routines/workouts that were
// created independently (e.g. by a sync hiccup, a restore, or manual testing) but
// carry the same real content are duplicates even though every id differs.
// MARK: - Plan
struct DuplicateCleanupPlan: Sendable {
struct RoutineGroup: Sendable, Identifiable {
/// Members that survive — always non-empty when the group is emitted.
var keep: [RoutineDocument]
/// Members slated for deletion — always non-empty when the group is emitted.
var delete: [RoutineDocument]
var id: String {
keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
}
}
struct WorkoutGroup: Sendable, Identifiable {
var keep: WorkoutDocument
var delete: [WorkoutDocument]
var id: String { keep.id }
}
var routineGroups: [RoutineGroup]
var workoutGroups: [WorkoutGroup]
var isEmpty: Bool { routineGroups.isEmpty && workoutGroups.isEmpty }
/// Total number of documents this plan would delete across every group.
var deleteCount: Int {
routineGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
}
}
// MARK: - Planner
enum DuplicateCleanupPlanner {
/// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered
/// documents. `referencedRoutineIDs` must include every routine id any workout
/// or schedule (readable or quarantined) points at, plus any clone-redirect
/// targets — the caller (`SyncEngine.scanForDuplicates`) is responsible for
/// assembling it.
static func plan(
routines: [RoutineDocument],
workouts: [WorkoutDocument],
referencedRoutineIDs: Set<String>,
isSeed: (String) -> Bool
) -> DuplicateCleanupPlan {
DuplicateCleanupPlan(
routineGroups: planRoutines(routines, referencedRoutineIDs: referencedRoutineIDs, isSeed: isSeed),
workoutGroups: planWorkouts(workouts)
)
}
// MARK: Routines
/// Hashable projection of a `MachineSetting`. The fingerprints keep the
/// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
/// and the user-defined order, both of which are real content.
private struct MachineSettingFingerprint: Hashable {
var name: String
var value: String
}
private static func fingerprint(_ settings: [MachineSetting]?) -> [MachineSettingFingerprint]? {
settings.map { $0.map { MachineSettingFingerprint(name: $0.name, value: $0.value) } }
}
/// Content-only projection of an exercise: ignores id and order (order is
/// captured by the parent's sort, not the element itself).
private struct ExerciseFingerprint: Hashable {
var name: String
var sets: Int
var reps: Int
var weight: Double
var loadType: Int
var durationSeconds: Int
var machineSettings: [MachineSettingFingerprint]?
}
/// Content-only projection of a routine: ignores id, dates, color, systemImage,
/// order, and activityType.
private struct RoutineFingerprint: Hashable {
var name: String
var exercises: [ExerciseFingerprint]
}
private static func fingerprint(_ doc: RoutineDocument) -> RoutineFingerprint {
RoutineFingerprint(
name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
exercises: doc.exercises.sorted { $0.order < $1.order }.map {
ExerciseFingerprint(
name: $0.name, sets: $0.sets, reps: $0.reps, weight: $0.weight,
loadType: $0.loadType, durationSeconds: $0.durationSeconds,
machineSettings: fingerprint($0.machineSettings)
)
}
)
}
/// Survivor rules (safety-critical — see file header):
/// 1. A member referenced by any workout is always kept.
/// 2. A member that's a bundled seed is always kept.
/// 3. If neither rule protects anyone, the lexicographically smallest id
/// (ULIDs sort chronologically) survives — deterministic across devices.
/// Everyone else in the group is deleted. A group with nothing to delete
/// (e.g. every member is protected) is dropped entirely.
private static func planRoutines(
_ routines: [RoutineDocument],
referencedRoutineIDs: Set<String>,
isSeed: (String) -> Bool
) -> [DuplicateCleanupPlan.RoutineGroup] {
let grouped = Dictionary(grouping: routines, by: fingerprint)
var groups: [DuplicateCleanupPlan.RoutineGroup] = []
for (_, members) in grouped where members.count > 1 {
var keep: [RoutineDocument] = []
var candidates: [RoutineDocument] = []
for member in members {
if referencedRoutineIDs.contains(member.id) || isSeed(member.id) {
keep.append(member)
} else {
candidates.append(member)
}
}
if keep.isEmpty, let survivorID = candidates.map(\.id).min() {
keep = candidates.filter { $0.id == survivorID }
candidates.removeAll { $0.id == survivorID }
}
guard !candidates.isEmpty else { continue }
groups.append(DuplicateCleanupPlan.RoutineGroup(
keep: keep.sorted { $0.id < $1.id },
delete: candidates.sorted { $0.id < $1.id }
))
}
return groups.sorted { $0.id < $1.id }
}
// MARK: Workouts
/// Content-only projection of a log: ignores id and the started/completed
/// timestamps.
private struct WorkoutLogFingerprint: Hashable {
var exerciseName: String
var order: Int
var sets: Int
var reps: Int
var weight: Double
var loadType: Int
var durationSeconds: Int
var status: String
var currentStateIndex: Int
var notes: String
var machineSettings: [MachineSettingFingerprint]?
}
/// Content-only projection of a workout: ignores id, createdAt/updatedAt,
/// startedAt/completedAt, exact start/end time (only the calendar day of
/// `start` matters), and metrics.
private struct WorkoutFingerprint: Hashable {
var routineID: String
var routineName: String
var year: Int
var month: Int
var day: Int
var status: String
var logs: [WorkoutLogFingerprint]
}
private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint {
let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start)
return WorkoutFingerprint(
routineID: doc.routineID ?? "",
routineName: doc.routineName ?? "",
year: comps.year ?? 0,
month: comps.month ?? 0,
day: comps.day ?? 0,
status: doc.status,
logs: doc.logs.sorted { $0.order < $1.order }.map {
WorkoutLogFingerprint(
exerciseName: $0.exerciseName, order: $0.order, sets: $0.sets, reps: $0.reps,
weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds,
status: $0.status, currentStateIndex: $0.currentStateIndex,
notes: $0.notes ?? "",
machineSettings: fingerprint($0.machineSettings)
)
}
)
}
/// Ranks a workout for survivorship — lower sorts first (wins): (1) holds the
/// HealthKit link (deleting that copy would cascade-delete the real Health
/// sample), (2) has metrics at all, (3) smallest id.
private static func survivorRank(_ doc: WorkoutDocument) -> (Int, Int, String) {
(doc.metrics?.healthKitWorkoutUUID != nil ? 0 : 1,
doc.metrics != nil ? 0 : 1,
doc.id)
}
/// If any member of a duplicate group is in progress, the whole group is
/// dropped — never touch an active workout, since the watch matches its live
/// run by workout id. Otherwise the survivor is picked by `survivorRank` and
/// everyone else is deleted.
private static func planWorkouts(_ workouts: [WorkoutDocument]) -> [DuplicateCleanupPlan.WorkoutGroup] {
let grouped = Dictionary(grouping: workouts, by: fingerprint)
var groups: [DuplicateCleanupPlan.WorkoutGroup] = []
for (_, members) in grouped where members.count > 1 {
if members.contains(where: { $0.status == WorkoutStatus.inProgress.rawValue }) { continue }
let ranked = members.sorted { a, b in
let ra = survivorRank(a), rb = survivorRank(b)
if ra.0 != rb.0 { return ra.0 < rb.0 }
if ra.1 != rb.1 { return ra.1 < rb.1 }
return ra.2 < rb.2
}
guard let survivor = ranked.first else { continue }
let toDelete = ranked.dropFirst().sorted { $0.id < $1.id }
groups.append(DuplicateCleanupPlan.WorkoutGroup(keep: survivor, delete: Array(toDelete)))
}
return groups.sorted { $0.id < $1.id }
}
}