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
This commit is contained in:
@@ -3,22 +3,22 @@ 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
|
||||
// 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 splits/workouts that were
|
||||
// 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 SplitGroup: Sendable, Identifiable {
|
||||
struct RoutineGroup: Sendable, Identifiable {
|
||||
/// Members that survive — always non-empty when the group is emitted.
|
||||
var keep: [SplitDocument]
|
||||
var keep: [RoutineDocument]
|
||||
/// Members slated for deletion — always non-empty when the group is emitted.
|
||||
var delete: [SplitDocument]
|
||||
var delete: [RoutineDocument]
|
||||
|
||||
var id: String {
|
||||
keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
|
||||
@@ -32,14 +32,14 @@ struct DuplicateCleanupPlan: Sendable {
|
||||
var id: String { keep.id }
|
||||
}
|
||||
|
||||
var splitGroups: [SplitGroup]
|
||||
var routineGroups: [RoutineGroup]
|
||||
var workoutGroups: [WorkoutGroup]
|
||||
|
||||
var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty }
|
||||
var isEmpty: Bool { routineGroups.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 }
|
||||
routineGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,22 +47,23 @@ struct DuplicateCleanupPlan: Sendable {
|
||||
|
||||
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.
|
||||
/// 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(
|
||||
splits: [SplitDocument],
|
||||
routines: [RoutineDocument],
|
||||
workouts: [WorkoutDocument],
|
||||
referencedSplitIDs: Set<String>,
|
||||
referencedRoutineIDs: Set<String>,
|
||||
isSeed: (String) -> Bool
|
||||
) -> DuplicateCleanupPlan {
|
||||
DuplicateCleanupPlan(
|
||||
splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed),
|
||||
routineGroups: planRoutines(routines, referencedRoutineIDs: referencedRoutineIDs, isSeed: isSeed),
|
||||
workoutGroups: planWorkouts(workouts)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Splits
|
||||
// MARK: Routines
|
||||
|
||||
/// Hashable projection of a `MachineSetting`. The fingerprints keep the
|
||||
/// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
|
||||
@@ -88,15 +89,15 @@ enum DuplicateCleanupPlanner {
|
||||
var machineSettings: [MachineSettingFingerprint]?
|
||||
}
|
||||
|
||||
/// Content-only projection of a split: ignores id, dates, color, systemImage,
|
||||
/// Content-only projection of a routine: ignores id, dates, color, systemImage,
|
||||
/// order, and activityType.
|
||||
private struct SplitFingerprint: Hashable {
|
||||
private struct RoutineFingerprint: Hashable {
|
||||
var name: String
|
||||
var exercises: [ExerciseFingerprint]
|
||||
}
|
||||
|
||||
private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint {
|
||||
SplitFingerprint(
|
||||
private static func fingerprint(_ doc: RoutineDocument) -> RoutineFingerprint {
|
||||
RoutineFingerprint(
|
||||
name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
exercises: doc.exercises.sorted { $0.order < $1.order }.map {
|
||||
ExerciseFingerprint(
|
||||
@@ -115,19 +116,19 @@ enum DuplicateCleanupPlanner {
|
||||
/// (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>,
|
||||
private static func planRoutines(
|
||||
_ routines: [RoutineDocument],
|
||||
referencedRoutineIDs: Set<String>,
|
||||
isSeed: (String) -> Bool
|
||||
) -> [DuplicateCleanupPlan.SplitGroup] {
|
||||
let grouped = Dictionary(grouping: splits, by: fingerprint)
|
||||
var groups: [DuplicateCleanupPlan.SplitGroup] = []
|
||||
) -> [DuplicateCleanupPlan.RoutineGroup] {
|
||||
let grouped = Dictionary(grouping: routines, by: fingerprint)
|
||||
var groups: [DuplicateCleanupPlan.RoutineGroup] = []
|
||||
|
||||
for (_, members) in grouped where members.count > 1 {
|
||||
var keep: [SplitDocument] = []
|
||||
var candidates: [SplitDocument] = []
|
||||
var keep: [RoutineDocument] = []
|
||||
var candidates: [RoutineDocument] = []
|
||||
for member in members {
|
||||
if referencedSplitIDs.contains(member.id) || isSeed(member.id) {
|
||||
if referencedRoutineIDs.contains(member.id) || isSeed(member.id) {
|
||||
keep.append(member)
|
||||
} else {
|
||||
candidates.append(member)
|
||||
@@ -138,7 +139,7 @@ enum DuplicateCleanupPlanner {
|
||||
candidates.removeAll { $0.id == survivorID }
|
||||
}
|
||||
guard !candidates.isEmpty else { continue }
|
||||
groups.append(DuplicateCleanupPlan.SplitGroup(
|
||||
groups.append(DuplicateCleanupPlan.RoutineGroup(
|
||||
keep: keep.sorted { $0.id < $1.id },
|
||||
delete: candidates.sorted { $0.id < $1.id }
|
||||
))
|
||||
@@ -169,8 +170,8 @@ enum DuplicateCleanupPlanner {
|
||||
/// 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 routineID: String
|
||||
var routineName: String
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
@@ -181,8 +182,8 @@ enum DuplicateCleanupPlanner {
|
||||
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 ?? "",
|
||||
routineID: doc.routineID ?? "",
|
||||
routineName: doc.routineName ?? "",
|
||||
year: comps.year ?? 0,
|
||||
month: comps.month ?? 0,
|
||||
day: comps.day ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user