Every completed set now writes a SetEntry (reps/weight or seconds), pre-filled from the plan by transition(to:) so the list checkbox, both run flows, and One More all capture for free; reset clears, skip keeps partials. The rest and finish pages show the just-done set as a pill that opens a stepper sheet for correcting reps and weight (2.5 lb / 1.25 kg steps). The Weight Progression chart plots the top-set actual weight and workout volume sums recorded sets, both falling back to the plan for legacy logs via effectiveSetEntries. Storage side of UX #3 rides along: plan weights are Double now. Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional weight fails an older Int decode, and a rewrite would strip the irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is reserved for the future cross-device log merge. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
236 lines
9.5 KiB
Swift
236 lines
9.5 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-split-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 splits/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 SplitGroup: Sendable, Identifiable {
|
|
/// Members that survive — always non-empty when the group is emitted.
|
|
var keep: [SplitDocument]
|
|
/// Members slated for deletion — always non-empty when the group is emitted.
|
|
var delete: [SplitDocument]
|
|
|
|
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 splitGroups: [SplitGroup]
|
|
var workoutGroups: [WorkoutGroup]
|
|
|
|
var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty }
|
|
|
|
/// Total number of documents this plan would delete across every group.
|
|
var deleteCount: Int {
|
|
splitGroups.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. `referencedSplitIDs` must include every split id any workout
|
|
/// (readable or quarantined) points at, plus any clone-redirect targets — the
|
|
/// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it.
|
|
static func plan(
|
|
splits: [SplitDocument],
|
|
workouts: [WorkoutDocument],
|
|
referencedSplitIDs: Set<String>,
|
|
isSeed: (String) -> Bool
|
|
) -> DuplicateCleanupPlan {
|
|
DuplicateCleanupPlan(
|
|
splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed),
|
|
workoutGroups: planWorkouts(workouts)
|
|
)
|
|
}
|
|
|
|
// MARK: Splits
|
|
|
|
/// 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 split: ignores id, dates, color, systemImage,
|
|
/// order, and activityType.
|
|
private struct SplitFingerprint: Hashable {
|
|
var name: String
|
|
var exercises: [ExerciseFingerprint]
|
|
}
|
|
|
|
private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint {
|
|
SplitFingerprint(
|
|
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 planSplits(
|
|
_ splits: [SplitDocument],
|
|
referencedSplitIDs: Set<String>,
|
|
isSeed: (String) -> Bool
|
|
) -> [DuplicateCleanupPlan.SplitGroup] {
|
|
let grouped = Dictionary(grouping: splits, by: fingerprint)
|
|
var groups: [DuplicateCleanupPlan.SplitGroup] = []
|
|
|
|
for (_, members) in grouped where members.count > 1 {
|
|
var keep: [SplitDocument] = []
|
|
var candidates: [SplitDocument] = []
|
|
for member in members {
|
|
if referencedSplitIDs.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.SplitGroup(
|
|
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 splitID: String
|
|
var splitName: 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(
|
|
splitID: doc.splitID ?? "",
|
|
splitName: doc.splitName ?? "",
|
|
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 }
|
|
}
|
|
}
|