Reconcile starter seeds on connect and add restore + duplicate cleanup
Seeding now covers existing installs, not just empty containers: after the same settle delay as auto-seed, connect() branches to reconcileSeeds(), driven by a pure tested planner — upgrade an older seed revision in place (safe: clone-on-edit guarantees no user content at seed ULIDs), skip up-to-date/quarantined files, respect delete-veto stubs, and write missing seeds unless a same-name legacy split exists. Settings gains Restore Starter Splits (the one deliberate veto lift, writing current bundle bytes) and a dev duplicate-cleanup tool backed by a fail-closed scanner. The HealthKit estimate now follows the clone redirect when resolving a workout's split. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -69,8 +69,9 @@ All in `Shared/Model/`:
|
|||||||
|
|
||||||
### Starter Data (deterministic seeds)
|
### Starter Data (deterministic seeds)
|
||||||
- **Starter splits**: shipped as byte-canonical `SplitDocument` JSON in `Workouts/Resources/StarterSplits/*.split.json` with **fixed ULIDs** (shared `01DXF6DT00` prefix, frozen 2020 timestamp) and fixed content, regenerated only by `Scripts/generate_starter_splits.swift`. `SeedLibrary` (`Workouts/Seed/`) loads the catalog; **seeds are immutable** — `SyncEngine.save(split:)` transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap via `sync.currentSplitID(for:)`).
|
- **Starter splits**: shipped as byte-canonical `SplitDocument` JSON in `Workouts/Resources/StarterSplits/*.split.json` with **fixed ULIDs** (shared `01DXF6DT00` prefix, frozen 2020 timestamp) and fixed content, regenerated only by `Scripts/generate_starter_splits.swift`. `SeedLibrary` (`Workouts/Seed/`) loads the catalog; **seeds are immutable** — `SyncEngine.save(split:)` transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap via `sync.currentSplitID(for:)`).
|
||||||
- **Auto-seed**: `SyncEngine.autoSeedIfEmpty()` writes the verbatim bundle bytes after connect, only into a verifiably empty container (no data files, no stubs) re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and stubs reap resurrected seeds. The on-demand path (`SplitSeeder.seedDefaults`, "Add Starter Splits") restores deleted seeds (lifting the veto stub) or writes missing ones, skipping live names.
|
- **Auto-seed & reconcile**: after connect + reconcile, `SyncEngine.seedOrReconcile()` (deferred, settle-delayed) branches on container state so the two seeders can never both fire. An empty container → `autoSeedIfEmpty()` writes the verbatim bundle bytes (re-checked after the settle delay). A non-empty container → `reconcileSeeds()` diffs each bundled seed against its fixed-ULID file via the pure `SeedReconcilePlanner` (`Workouts/Sync/SeedReconcile.swift`): a stale revision is overwritten with current bundle bytes (a **semantic** compare with `schemaVersion` normalized — never a byte compare, since older files carry now-removed keys), a newly-shipped seed with no file and no stub is written (unless a live same-name split already exists, or a newer app version wrote the file), and a deleted seed (stub present) is left vetoed. Wrong guesses are harmless — identical bytes make same-path conflicts empty, stubs reap resurrected seeds, and the veto + name guards are re-checked right before each write. This is safe because a fixed-ULID file can never hold user content (every edit forks to a fresh ULID).
|
||||||
- **Exercise library**: authored in `Exercise Library/` at the repo root (per-exercise `info.md`, SVG visuals, motion rigs, Python render pipeline); the app bundles the exported `Workouts/Resources/ExerciseMotions/*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`).
|
- **Restore Starter Splits** (Settings) is the one deliberate veto lift: `SyncEngine.restoreSeeds()` removes a deleted seed's stub and rewrites the **current bundle bytes** (never the stub's old contents), still skipping seeds whose live file already exists (reconcile handles upgrades) or whose name collides with a live split; it returns the count for a brief in-Settings confirmation.
|
||||||
|
- **Exercise library**: authored in `Exercise Library/` at the repo root (per-exercise `info.md`, SVG visuals, motion rigs, Python render pipeline); the app bundles the exported `Workouts/Resources/ExerciseMotions/*` resources — `*.motion.json` rigs, which double as the exercise picker's list (`ExerciseMotionLibrary.exerciseNames`), and `*.info.md` reference pages, parsed by `ExerciseInfo` and rendered in the Settings → Library → Exercises detail screen. Re-export both with `python3 render.py --export`.
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -172,9 +172,15 @@ final class WorkoutHealthWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The split's configured activity type, following the seed clone-on-edit
|
||||||
|
/// redirect (the estimate can run seconds to hours after the workout, so the
|
||||||
|
/// split may have forked in between). Generic strength when the split is gone.
|
||||||
private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType {
|
private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType {
|
||||||
if let splitID = doc.splitID, let split = CacheMapper.fetchSplit(id: splitID, in: context) {
|
if let splitID = doc.splitID {
|
||||||
return split.activityTypeEnum
|
let resolved = syncEngine?.currentSplitID(for: splitID) ?? splitID
|
||||||
|
if let split = CacheMapper.fetchSplit(id: resolved, in: context) {
|
||||||
|
return split.activityTypeEnum
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return .traditionalStrength
|
return .traditionalStrength
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
/// The bundled immutable starter-split library, brought back on demand. The seeds
|
|
||||||
/// themselves — fixed ULIDs, byte-canonical bundle JSON — live in `SeedLibrary`;
|
|
||||||
/// editing one clones it and permanently tombstones the seed (see
|
|
||||||
/// `SyncEngine.save(split:)`). The true first-run case is handled automatically by
|
|
||||||
/// `SyncEngine.autoSeedIfEmpty`; this on-demand path (the "Add Starter Splits"
|
|
||||||
/// button) is for a user who wants the starters back after removing some.
|
|
||||||
enum SplitSeeder {
|
|
||||||
/// Add every starter split the user doesn't already have. For each seed:
|
|
||||||
/// • skip if a live split with the same NAME exists — a user's edited clone of
|
|
||||||
/// "Upper Body" must not be joined by a resurrected seed of the same name;
|
|
||||||
/// • else if the seed was deleted (tombstoned), restore it (lifting the veto);
|
|
||||||
/// • else write it fresh.
|
|
||||||
/// Seed `order` values are fixed (0–3); a collision with a user split's order is
|
|
||||||
/// an accepted cosmetic tie. Idempotent against double-taps and partial prior seeds.
|
|
||||||
@MainActor
|
|
||||||
static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async {
|
|
||||||
let existing = (try? context.fetch(FetchDescriptor<Split>())) ?? []
|
|
||||||
let existingNames = Set(existing.map(\.name))
|
|
||||||
|
|
||||||
for seed in SeedLibrary.seeds {
|
|
||||||
if existingNames.contains(seed.doc.name) { continue }
|
|
||||||
if await sync.isTombstoned(id: seed.id) {
|
|
||||||
await sync.restoreSeed(seed)
|
|
||||||
} else {
|
|
||||||
await sync.writeSeed(seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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: Int
|
||||||
|
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: Int
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Pure decision logic for bringing an install's on-disk starter-split library in
|
||||||
|
// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the
|
||||||
|
// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()`
|
||||||
|
// gathers the live seed-path files, stub set, and live split names, hands each seed to
|
||||||
|
// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write.
|
||||||
|
//
|
||||||
|
// Why this exists: starter seeds ship as byte-canonical `SplitDocument` JSON under
|
||||||
|
// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a
|
||||||
|
// verifiably empty container, so an existing install can never pick up an upgraded
|
||||||
|
// seed revision or a newly-shipped seed. This planner decides, per seed, whether the
|
||||||
|
// live fixed-ULID file is up to date, stale (needs the current bundle bytes), deleted
|
||||||
|
// (vetoed), or missing (write it).
|
||||||
|
//
|
||||||
|
// Safety invariant this relies on: a live file at a seed's fixed ULID can NEVER hold
|
||||||
|
// user-authored content — every user edit forks the seed to a fresh random ULID
|
||||||
|
// (clone-on-edit), and fixed-ULID files only exist on app versions that have
|
||||||
|
// clone-on-edit. So overwriting a seed-path file with current bundle bytes never
|
||||||
|
// destroys user data; at worst it upgrades an older seed revision.
|
||||||
|
|
||||||
|
// MARK: - Input
|
||||||
|
|
||||||
|
/// Everything the planner needs to decide one seed, pre-gathered by the engine.
|
||||||
|
struct SeedReconcileInput: Equatable {
|
||||||
|
/// The seed's fixed ULID.
|
||||||
|
let seedID: String
|
||||||
|
/// The bundle seed document (canonical current content).
|
||||||
|
let seedDoc: SplitDocument
|
||||||
|
/// The decoded live file currently sitting at the seed's fixed path, or `nil`
|
||||||
|
/// when no live file exists there (deleted, or never written).
|
||||||
|
let liveDoc: SplitDocument?
|
||||||
|
/// Whether a tombstone stub exists for this seed's id.
|
||||||
|
let hasStub: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decision
|
||||||
|
|
||||||
|
enum SeedReconcileDecision: Equatable {
|
||||||
|
/// Do nothing — the reason is carried for logging and tests.
|
||||||
|
case skip(SeedSkipReason)
|
||||||
|
/// A live file exists at the seed path but differs from the bundle: overwrite it
|
||||||
|
/// with the verbatim bundle bytes (an older seed revision).
|
||||||
|
case upgrade
|
||||||
|
/// No live file, no veto, no name collision: write the verbatim bundle bytes (a
|
||||||
|
/// newly-shipped seed, or a pre-fixed-ULID install missing the file).
|
||||||
|
case write
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SeedSkipReason: Equatable {
|
||||||
|
/// The live file already matches the bundle seed (schemaVersion-normalized).
|
||||||
|
case upToDate
|
||||||
|
/// The live file was written by a newer app version — never downgrade-rewrite it.
|
||||||
|
case quarantined
|
||||||
|
/// No live file, but a stub exists: the user deleted this seed. Respect the veto.
|
||||||
|
case vetoed
|
||||||
|
/// No live file and no stub, but a live split already uses this seed's name
|
||||||
|
/// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed
|
||||||
|
/// would duplicate it by name.
|
||||||
|
case nameCollision
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planner
|
||||||
|
|
||||||
|
enum SeedReconcilePlanner {
|
||||||
|
/// The automatic on-connect decision for one seed (see the decision table in the
|
||||||
|
/// file header). `liveSplitNames` must be the names of every live split currently
|
||||||
|
/// in the container/cache after reconcile — the name-collision guard consults it.
|
||||||
|
static func decision(
|
||||||
|
for input: SeedReconcileInput,
|
||||||
|
liveSplitNames: Set<String>
|
||||||
|
) -> SeedReconcileDecision {
|
||||||
|
if let live = input.liveDoc {
|
||||||
|
// A live file sits at the seed path. Never touch one a newer app version
|
||||||
|
// wrote (the forward-compat quarantine), otherwise compare content.
|
||||||
|
guard live.isReadable else { return .skip(.quarantined) }
|
||||||
|
return semanticallyEqual(live: live, seed: input.seedDoc)
|
||||||
|
? .skip(.upToDate)
|
||||||
|
: .upgrade
|
||||||
|
}
|
||||||
|
// No live file at the seed path.
|
||||||
|
if input.hasStub { return .skip(.vetoed) }
|
||||||
|
if liveSplitNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
|
||||||
|
return .write
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic (not byte) equality of a live seed file against the bundle seed.
|
||||||
|
///
|
||||||
|
/// A byte compare is wrong here: a container file may have been written by an
|
||||||
|
/// older app version, carrying extra now-removed keys and a lower `schemaVersion`.
|
||||||
|
/// Decoding both into the current `SplitDocument` already drops unknown keys, so
|
||||||
|
/// the only remaining artificial difference is the version stamp — normalize the
|
||||||
|
/// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
|
||||||
|
/// identical content reads as up to date (no churn), while a genuine content
|
||||||
|
/// change reads as different (upgrade).
|
||||||
|
static func semanticallyEqual(live: SplitDocument, seed: SplitDocument) -> Bool {
|
||||||
|
var normalized = live
|
||||||
|
normalized.schemaVersion = seed.schemaVersion
|
||||||
|
return normalized == seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the deliberate "Restore Starter Splits" action should (re)write a seed:
|
||||||
|
/// only when no live file sits at its path and no live split already uses its name.
|
||||||
|
/// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but
|
||||||
|
/// the engine still removes the stub as a separate execution step. A seed whose
|
||||||
|
/// live file already exists is left for `decision(...)` to upgrade.
|
||||||
|
static func shouldRestore(
|
||||||
|
hasLiveFile: Bool,
|
||||||
|
seedName: String,
|
||||||
|
liveSplitNames: Set<String>
|
||||||
|
) -> Bool {
|
||||||
|
!hasLiveFile && !liveSplitNames.contains(seedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
-54
@@ -11,8 +11,12 @@ enum ICloudStatus: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
||||||
/// the sole source of truth: every save/delete writes files only; the metadata
|
/// the sole source of truth: every save/delete writes the file first, then
|
||||||
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
|
/// mirrors the change into the cache immediately (a same-process write doesn't
|
||||||
|
/// reliably wake the `NSMetadataQuery` observer — and never does in the
|
||||||
|
/// simulator — so waiting on it leaves the UI blind to the user's own action).
|
||||||
|
/// The observer and the connect-time reconcile re-apply idempotently and remain
|
||||||
|
/// the sole channel for *remote* changes.
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SyncEngine {
|
final class SyncEngine {
|
||||||
@@ -27,8 +31,9 @@ final class SyncEngine {
|
|||||||
|
|
||||||
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
|
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
|
||||||
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
|
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
|
||||||
/// notifies on change); not persisted — a seed is edited at most once before it's
|
/// notifies on change); not persisted — durability comes from `repointWorkouts`,
|
||||||
/// gone, so nothing needs to survive a relaunch.
|
/// which rewrites `splitID` on the workout documents themselves at fork time.
|
||||||
|
/// This map only bridges views that captured the seed's id before the swap.
|
||||||
private(set) var cloneRedirects: [String: String] = [:]
|
private(set) var cloneRedirects: [String: String] = [:]
|
||||||
|
|
||||||
/// Follow the redirect chain from `id` to the current live split id. A seed
|
/// Follow the redirect chain from `id` to the current live split id. A seed
|
||||||
@@ -154,7 +159,7 @@ final class SyncEngine {
|
|||||||
startMonitoring(documentsURL: store.rootURL)
|
startMonitoring(documentsURL: store.rootURL)
|
||||||
cleanupOldStubs()
|
cleanupOldStubs()
|
||||||
// Off the connect path so opening the gate isn't delayed by the settle wait.
|
// Off the connect path so opening the gate isn't delayed by the settle wait.
|
||||||
Task { await self.autoSeedIfEmpty() }
|
Task { await self.seedOrReconcile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invoked from the connecting screen when the user chooses not to keep
|
/// Invoked from the connecting screen when the user chooses not to keep
|
||||||
@@ -205,15 +210,8 @@ final class SyncEngine {
|
|||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a workout received from the watch. iCloud Drive stays the source of
|
/// Apply a workout received from the watch — `save(workout:)` writes the file
|
||||||
/// truth (we write the file), but we also upsert the cache directly here.
|
/// and mirrors it into the cache, same as a local edit.
|
||||||
///
|
|
||||||
/// The phone's own edits drive a local view copy, so they don't need this — but a
|
|
||||||
/// watch-originated change has nothing else refreshing the phone UI, and a
|
|
||||||
/// same-process file overwrite doesn't reliably wake the `NSMetadataQuery`
|
|
||||||
/// observer. Upserting the doc we just wrote keeps cache and file consistent (the
|
|
||||||
/// observer re-applies idempotently if it does fire) and lets open phone screens
|
|
||||||
/// reflect watch progress live.
|
|
||||||
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
||||||
// A workout deleted on the phone leaves a tombstone; a watch that missed the
|
// A workout deleted on the phone leaves a tombstone; a watch that missed the
|
||||||
// delete may still push its stale copy. Honor the veto — never resurrect it —
|
// delete may still push its stale copy. Honor the veto — never resurrect it —
|
||||||
@@ -224,9 +222,6 @@ final class SyncEngine {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
await save(workout: doc)
|
await save(workout: doc)
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
|
||||||
do { try context.save() } catch { report("Cache save failed", error) }
|
|
||||||
onCacheChanged?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public CRUD (write path: files only)
|
// MARK: - Public CRUD (write path: files only)
|
||||||
@@ -249,11 +244,12 @@ final class SyncEngine {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await store.write(doc, to: doc.relativePath)
|
try await store.write(doc, to: doc.relativePath)
|
||||||
|
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
|
||||||
|
saveCacheAndNotify()
|
||||||
lastSyncError = nil
|
lastSyncError = nil
|
||||||
} catch {
|
} catch {
|
||||||
report("Failed to save split", error)
|
report("Failed to save split", error)
|
||||||
}
|
}
|
||||||
// Cache updates reactively via the monitor.
|
|
||||||
return doc.id
|
return doc.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +275,7 @@ final class SyncEngine {
|
|||||||
try context.save()
|
try context.save()
|
||||||
cloneRedirects[doc.id] = clone.id
|
cloneRedirects[doc.id] = clone.id
|
||||||
lastSyncError = nil
|
lastSyncError = nil
|
||||||
|
await repointWorkouts(from: doc.id, to: clone.id)
|
||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
return clone.id
|
return clone.id
|
||||||
} catch {
|
} catch {
|
||||||
@@ -287,10 +284,39 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rewrite `splitID` on every workout that references `oldID`, so split lookups
|
||||||
|
/// (category grouping, add-exercise, plan mirroring, health estimates) keep
|
||||||
|
/// resolving after a seed fork — durably, across relaunches, and on the watch,
|
||||||
|
/// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen
|
||||||
|
/// at what the workout was started as, matching rename semantics for regular
|
||||||
|
/// splits. Best effort per workout: a failed rewrite is reported and the redirect
|
||||||
|
/// map still covers it for this session.
|
||||||
|
private func repointWorkouts(from oldID: String, to newID: String) async {
|
||||||
|
guard let store else { return }
|
||||||
|
let referencing = (try? context.fetch(
|
||||||
|
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == oldID })
|
||||||
|
)) ?? []
|
||||||
|
guard !referencing.isEmpty else { return }
|
||||||
|
for workout in referencing {
|
||||||
|
var wDoc = WorkoutDocument(from: workout)
|
||||||
|
wDoc.splitID = newID
|
||||||
|
wDoc.updatedAt = Date()
|
||||||
|
do {
|
||||||
|
try await store.write(wDoc, to: wDoc.relativePath)
|
||||||
|
CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context)
|
||||||
|
} catch {
|
||||||
|
report("Failed to repoint workout \(wDoc.id) at edited split", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try context.save() } catch { report("Cache save failed", error) }
|
||||||
|
}
|
||||||
|
|
||||||
func save(workout doc: WorkoutDocument) async {
|
func save(workout doc: WorkoutDocument) async {
|
||||||
guard let store else { return }
|
guard let store else { return }
|
||||||
do {
|
do {
|
||||||
try await store.write(doc, to: doc.relativePath)
|
try await store.write(doc, to: doc.relativePath)
|
||||||
|
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||||
|
saveCacheAndNotify()
|
||||||
lastSyncError = nil
|
lastSyncError = nil
|
||||||
} catch {
|
} catch {
|
||||||
report("Failed to save workout", error)
|
report("Failed to save workout", error)
|
||||||
@@ -302,11 +328,24 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(split: Split) async {
|
func delete(split: Split) async {
|
||||||
await softDelete(id: split.id, kind: "split", livePath: split.jsonRelativePath)
|
let id = split.id, livePath = split.jsonRelativePath
|
||||||
|
await softDelete(id: id, kind: "split", livePath: livePath)
|
||||||
|
deleteCachedEntity(id: id)
|
||||||
|
saveCacheAndNotify()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(workout: Workout) async {
|
func delete(workout: Workout) async {
|
||||||
await softDelete(id: workout.id, kind: "workout", livePath: workout.jsonRelativePath)
|
let id = workout.id, livePath = workout.jsonRelativePath
|
||||||
|
await softDelete(id: id, kind: "workout", livePath: livePath)
|
||||||
|
deleteCachedEntity(id: id)
|
||||||
|
saveCacheAndNotify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist pending cache mutations and fan out the change notification — the
|
||||||
|
/// tail of every local write's immediate cache mirror.
|
||||||
|
private func saveCacheAndNotify() {
|
||||||
|
do { try context.save() } catch { report("Cache save failed", error) }
|
||||||
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a tombstone stub then removes the live file. Other devices learn of
|
/// Writes a tombstone stub then removes the live file. Other devices learn of
|
||||||
@@ -502,47 +541,266 @@ final class SyncEngine {
|
|||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The
|
/// Post-connect seed maintenance, deferred off the connect path. Settles first —
|
||||||
/// on-demand "Add Starter Splits" path uses this for a seed that's neither present
|
/// an empty or sparse listing right after connect can just mean the metadata index
|
||||||
/// nor tombstoned.
|
/// / placeholder files haven't surfaced existing data yet — then branches so the
|
||||||
func writeSeed(_ seed: SeedLibrary.Seed) async {
|
/// two seeders can never both fire: an empty container is first-run auto-seed's
|
||||||
guard let store else { return }
|
/// alone; a non-empty one is reconciled against the current bundle.
|
||||||
do {
|
private func seedOrReconcile() async {
|
||||||
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
// Give existing files a chance to surface before deciding empty-vs-reconcile
|
||||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
// (the same caution `autoSeedIfEmpty` also takes internally).
|
||||||
try context.save()
|
try? await Task.sleep(for: .seconds(5))
|
||||||
lastSyncError = nil
|
guard iCloudStatus == .available else { return }
|
||||||
} catch {
|
if await containerEmpty() {
|
||||||
report("Failed to add starter split \(seed.doc.name)", error)
|
await autoSeedIfEmpty()
|
||||||
|
} else {
|
||||||
|
await reconcileSeeds()
|
||||||
}
|
}
|
||||||
onCacheChanged?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring a previously-deleted seed back: lift its veto stub, then write its
|
/// Bring the on-disk seed set in line with the current bundle on a NON-empty
|
||||||
/// verbatim bundle bytes. The bundle is the canonical restore source (seed content
|
/// container (autoSeedIfEmpty owns the empty case). For each bundled seed, decide
|
||||||
/// is immutable), so this restores from the bundle rather than reconstructing from
|
/// via the pure `SeedReconcilePlanner`:
|
||||||
/// the stub — the seed's stub is only ever a minimal veto marker. The stub is
|
/// • live file present, up to date → skip
|
||||||
/// removed first so neither a racing observer event nor a reconcile re-vetoes the
|
/// • live file present, older revision → overwrite with bundle bytes (upgrade)
|
||||||
/// file we're about to write while the stub still exists.
|
/// • live file present, newer app version → skip (quarantine — never downgrade)
|
||||||
func restoreSeed(_ seed: SeedLibrary.Seed) async {
|
/// • no live file, stub present → skip (user deleted it; veto stands)
|
||||||
|
/// • no live file, no stub, name in use → skip (don't duplicate a same-name split)
|
||||||
|
/// • no live file, no stub, name free → write bundle bytes
|
||||||
|
///
|
||||||
|
/// Safe even on a stale metadata index: writing bundle bytes another device also
|
||||||
|
/// wrote is a semantically empty conflict, and the stub + name guards are re-checked
|
||||||
|
/// right before each fresh write.
|
||||||
|
private func reconcileSeeds() async {
|
||||||
guard let store, let tombstones else { return }
|
guard let store, let tombstones else { return }
|
||||||
do {
|
|
||||||
try await tombstones.removeStub(at: "\(seed.id).json")
|
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
|
||||||
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
let stubIDs = await tombstones.listStubIDs()
|
||||||
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||||
try context.save()
|
|
||||||
lastSyncError = nil
|
var didChange = false
|
||||||
} catch {
|
for seed in SeedLibrary.seeds {
|
||||||
report("Failed to restore starter split \(seed.doc.name)", error)
|
// Decode the live file at the seed's fixed path, if one exists. A file that
|
||||||
|
// exists but can't be read right now (evicted + download timed out) is left
|
||||||
|
// untouched — a later connect retries it.
|
||||||
|
var liveDoc: SplitDocument?
|
||||||
|
if livePaths.contains(seed.doc.relativePath) {
|
||||||
|
guard let data = try? await store.readData(from: seed.doc.relativePath),
|
||||||
|
let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
liveDoc = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = SeedReconcileInput(
|
||||||
|
seedID: seed.id, seedDoc: seed.doc, liveDoc: liveDoc,
|
||||||
|
hasStub: stubIDs.contains(seed.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
|
||||||
|
case .skip:
|
||||||
|
continue
|
||||||
|
case .upgrade:
|
||||||
|
// Overwriting an existing seed-path file with canonical bundle bytes is
|
||||||
|
// always safe (the file can only ever be an older seed revision).
|
||||||
|
if await writeSeedBytes(seed) { didChange = true }
|
||||||
|
case .write:
|
||||||
|
// Re-check the veto and name guards against fresh state — the metadata
|
||||||
|
// index may have moved since the batch was gathered.
|
||||||
|
if await tombstones.stubExists(id: seed.id) { continue }
|
||||||
|
let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||||
|
if names.contains(seed.doc.name) { continue }
|
||||||
|
if await writeSeedBytes(seed) { didChange = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if didChange {
|
||||||
|
do { try context.save() } catch { report("Cache save failed", error) }
|
||||||
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
onCacheChanged?()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed
|
/// Force-restore the starter library on explicit user request ("Restore Starter
|
||||||
/// path checks this to restore rather than re-create a deleted seed.
|
/// Splits"). For each seed with no live file and no live same-name split, lift its
|
||||||
func isTombstoned(id: String) async -> Bool {
|
/// veto stub if present — the ONE place the forever-veto is deliberately lifted —
|
||||||
guard let tombstones else { return false }
|
/// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is
|
||||||
return await tombstones.stubExists(id: id)
|
/// the canonical restore source). Seeds whose live file already exists are left for
|
||||||
|
/// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written.
|
||||||
|
@discardableResult
|
||||||
|
func restoreSeeds() async -> Int {
|
||||||
|
guard let store, let tombstones else { return 0 }
|
||||||
|
|
||||||
|
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
|
||||||
|
|
||||||
|
var restored = 0
|
||||||
|
for seed in SeedLibrary.seeds {
|
||||||
|
let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
|
||||||
|
guard SeedReconcilePlanner.shouldRestore(
|
||||||
|
hasLiveFile: livePaths.contains(seed.doc.relativePath),
|
||||||
|
seedName: seed.doc.name,
|
||||||
|
liveSplitNames: liveNames
|
||||||
|
) else { continue }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Lift the veto: drop the seed's tombstone stub if one exists, then
|
||||||
|
// write the verbatim bundle bytes.
|
||||||
|
if await tombstones.stubExists(id: seed.id) {
|
||||||
|
try await tombstones.removeStub(at: "\(seed.id).json")
|
||||||
|
}
|
||||||
|
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
||||||
|
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||||
|
restored += 1
|
||||||
|
lastSyncError = nil
|
||||||
|
} catch {
|
||||||
|
report("Failed to restore starter split \(seed.doc.name)", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if restored > 0 {
|
||||||
|
do { try context.save() } catch { report("Cache save failed", error) }
|
||||||
|
onCacheChanged?()
|
||||||
|
}
|
||||||
|
return restored
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns
|
||||||
|
/// whether the write succeeded (so the caller can flush + notify once at the end).
|
||||||
|
@discardableResult
|
||||||
|
private func writeSeedBytes(_ seed: SeedLibrary.Seed) async -> Bool {
|
||||||
|
guard let store else { return false }
|
||||||
|
do {
|
||||||
|
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
||||||
|
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
||||||
|
lastSyncError = nil
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
report("Failed to reconcile starter split \(seed.doc.name)", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Duplicate cleanup (dev)
|
||||||
|
|
||||||
|
enum DuplicateScanError: Error, Sendable {
|
||||||
|
case notConnected
|
||||||
|
case unreadableFiles([String])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans every live (non-stub) document and builds a plan for exact-content
|
||||||
|
/// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only — no
|
||||||
|
/// files are touched. Fails closed: if ANY file can't be read or decoded, no
|
||||||
|
/// plan is produced, because an unreadable workout could reference any split
|
||||||
|
/// and silently proceeding could misjudge that split as unreferenced.
|
||||||
|
func scanForDuplicates() async throws -> DuplicateCleanupPlan {
|
||||||
|
guard let store else { throw DuplicateScanError.notConnected }
|
||||||
|
|
||||||
|
let paths = await store.list().filter { !$0.hasPrefix("Stubs/") }
|
||||||
|
|
||||||
|
var splits: [SplitDocument] = []
|
||||||
|
var workouts: [WorkoutDocument] = []
|
||||||
|
var referencedSplitIDs = Set<String>()
|
||||||
|
var failedPaths: [String] = []
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try await store.readData(from: path)
|
||||||
|
} catch {
|
||||||
|
log.error("scanForDuplicates: read failed for \(path, privacy: .public): \(error)")
|
||||||
|
failedPaths.append(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.hasPrefix("Splits/") {
|
||||||
|
do {
|
||||||
|
let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
|
||||||
|
// Quarantined (written by a newer app version) splits are never
|
||||||
|
// judged as duplicates or deleted.
|
||||||
|
if doc.isReadable { splits.append(doc) }
|
||||||
|
} catch {
|
||||||
|
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
|
||||||
|
failedPaths.append(path)
|
||||||
|
}
|
||||||
|
} else if path.hasPrefix("Workouts/") {
|
||||||
|
do {
|
||||||
|
let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
|
||||||
|
// A quarantined workout's splitID still protects that split from
|
||||||
|
// deletion even though the workout itself is excluded below.
|
||||||
|
if let splitID = doc.splitID {
|
||||||
|
referencedSplitIDs.insert(splitID)
|
||||||
|
referencedSplitIDs.insert(currentSplitID(for: splitID))
|
||||||
|
}
|
||||||
|
if doc.isReadable { workouts.append(doc) }
|
||||||
|
} catch {
|
||||||
|
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
|
||||||
|
failedPaths.append(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard failedPaths.isEmpty else {
|
||||||
|
throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
referencedSplitIDs.formUnion(cloneRedirects.values)
|
||||||
|
|
||||||
|
return DuplicateCleanupPlanner.plan(
|
||||||
|
splits: splits,
|
||||||
|
workouts: workouts,
|
||||||
|
referencedSplitIDs: referencedSplitIDs,
|
||||||
|
isSeed: SeedLibrary.isSeed(id:)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a previously scanned plan. Every deletion is re-checked
|
||||||
|
/// immediately beforehand against the *current* cache state — a watch push or
|
||||||
|
/// another device's write can land between scan and delete — so nothing
|
||||||
|
/// referenced or active is ever removed even if the plan is a few seconds stale.
|
||||||
|
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
|
||||||
|
var splitsDeleted = 0
|
||||||
|
var workoutsDeleted = 0
|
||||||
|
var skipped = 0
|
||||||
|
|
||||||
|
for group in plan.splitGroups {
|
||||||
|
for doc in group.delete {
|
||||||
|
let splitID = doc.id
|
||||||
|
if SeedLibrary.isSeed(id: splitID) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let referencingWorkouts = (try? context.fetch(
|
||||||
|
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
|
||||||
|
)) ?? []
|
||||||
|
guard referencingWorkouts.isEmpty else {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
|
||||||
|
await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
|
||||||
|
} else {
|
||||||
|
await softDelete(id: splitID, kind: "split", livePath: doc.relativePath)
|
||||||
|
}
|
||||||
|
deleteCachedEntity(id: splitID)
|
||||||
|
splitsDeleted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in plan.workoutGroups {
|
||||||
|
for doc in group.delete {
|
||||||
|
let cached = CacheMapper.fetchWorkout(id: doc.id, in: context)
|
||||||
|
if cached?.status == .inProgress {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let livePath = cached?.jsonRelativePath ?? doc.relativePath
|
||||||
|
await softDelete(id: doc.id, kind: "workout", livePath: livePath)
|
||||||
|
deleteCachedEntity(id: doc.id)
|
||||||
|
workoutsDeleted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCacheAndNotify()
|
||||||
|
return (splitsDeleted, workoutsDeleted, skipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Reporting
|
// MARK: - Error Reporting
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//
|
||||||
|
// DuplicateCleanupView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Developer-facing tool: scans iCloud Drive for splits/workouts that are exact
|
||||||
|
/// content duplicates (see `DuplicateCleanupPlanner`) — typically the residue of
|
||||||
|
/// testing, a restore, or a sync hiccup — and offers to delete the extras. Never
|
||||||
|
/// touches a split referenced by a workout, a bundled starter split, or an
|
||||||
|
/// in-progress workout.
|
||||||
|
struct DuplicateCleanupView: View {
|
||||||
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
|
||||||
|
private enum ScanFailure {
|
||||||
|
case unreadableFiles([String])
|
||||||
|
case other(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CleanupSummary {
|
||||||
|
var splitsDeleted: Int
|
||||||
|
var workoutsDeleted: Int
|
||||||
|
var skipped: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Phase {
|
||||||
|
case idle
|
||||||
|
case scanning
|
||||||
|
case results(DuplicateCleanupPlan)
|
||||||
|
case failed(ScanFailure)
|
||||||
|
case deleting
|
||||||
|
case done(CleanupSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
@State private var pendingPlan: DuplicateCleanupPlan?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.navigationTitle("Clean Up Duplicates")
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete \(pendingPlan?.deleteCount ?? 0) Duplicate\((pendingPlan?.deleteCount ?? 0) == 1 ? "" : "s")?",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingPlan != nil },
|
||||||
|
set: { if !$0 { pendingPlan = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: pendingPlan
|
||||||
|
) { plan in
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
pendingPlan = nil
|
||||||
|
Task { await performDelete(plan) }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingPlan = nil
|
||||||
|
}
|
||||||
|
} message: { _ in
|
||||||
|
Text("This permanently deletes the duplicate copies shown below. Referenced splits, starter splits, and in-progress workouts are always kept.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
idleView
|
||||||
|
case .scanning:
|
||||||
|
ProgressView("Scanning…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .results(let plan):
|
||||||
|
resultsView(plan)
|
||||||
|
case .failed(let failure):
|
||||||
|
failureView(failure)
|
||||||
|
case .deleting:
|
||||||
|
ProgressView("Deleting…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .done(let summary):
|
||||||
|
doneView(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Idle
|
||||||
|
|
||||||
|
private var idleView: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task { await scan() }
|
||||||
|
} label: {
|
||||||
|
Label("Scan for Duplicates", systemImage: "magnifyingglass")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Looks for splits and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Splits referenced by a workout, starter splits, and in-progress workouts are never deleted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Results
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func resultsView(_ plan: DuplicateCleanupPlan) -> some View {
|
||||||
|
if plan.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Duplicates Found",
|
||||||
|
systemImage: "checkmark.circle",
|
||||||
|
description: Text("Everything in iCloud looks unique.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if !plan.splitGroups.isEmpty {
|
||||||
|
Section("Duplicate Splits") {
|
||||||
|
ForEach(plan.splitGroups) { group in
|
||||||
|
splitGroupRow(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plan.workoutGroups.isEmpty {
|
||||||
|
Section("Duplicate Workouts") {
|
||||||
|
ForEach(plan.workoutGroups) { group in
|
||||||
|
workoutGroupRow(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
pendingPlan = plan
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("Delete \(plan.deleteCount) Duplicate\(plan.deleteCount == 1 ? "" : "s")")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitGroupRow(_ group: DuplicateCleanupPlan.SplitGroup) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(group.keep) { doc in
|
||||||
|
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: true)
|
||||||
|
}
|
||||||
|
ForEach(group.delete) { doc in
|
||||||
|
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func workoutGroupRow(_ group: DuplicateCleanupPlan.WorkoutGroup) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
memberRow(
|
||||||
|
name: group.keep.splitName ?? "(no split)",
|
||||||
|
detail: group.keep.start.formattedDate(),
|
||||||
|
id: group.keep.id,
|
||||||
|
isKeep: true
|
||||||
|
)
|
||||||
|
ForEach(group.delete) { doc in
|
||||||
|
memberRow(
|
||||||
|
name: doc.splitName ?? "(no split)",
|
||||||
|
detail: doc.start.formattedDate(),
|
||||||
|
id: doc.id,
|
||||||
|
isKeep: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memberRow(name: String, detail: String, id: String, isKeep: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(name)
|
||||||
|
Text("\(detail) · id …\(id.suffix(6))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isKeep {
|
||||||
|
Label("Keep", systemImage: "checkmark.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Failure
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func failureView(_ failure: ScanFailure) -> some View {
|
||||||
|
switch failure {
|
||||||
|
case .unreadableFiles(let paths):
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Label(
|
||||||
|
"Scan aborted: \(paths.count) file\(paths.count == 1 ? "" : "s") could not be read. Nothing was deleted.",
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Section("Unreadable Files") {
|
||||||
|
ForEach(paths, id: \.self) { path in
|
||||||
|
Text(path)
|
||||||
|
.font(.system(.footnote, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Rescan") {
|
||||||
|
Task { await scan() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .other(let message):
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Try Again") {
|
||||||
|
Task { await scan() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Done
|
||||||
|
|
||||||
|
private func doneView(_ summary: CleanupSummary) -> some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Label(
|
||||||
|
"Deleted \(summary.splitsDeleted) split\(summary.splitsDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
|
||||||
|
systemImage: "checkmark.circle.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Scan Again") {
|
||||||
|
Task { await scan() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func scan() async {
|
||||||
|
phase = .scanning
|
||||||
|
do {
|
||||||
|
let plan = try await sync.scanForDuplicates()
|
||||||
|
phase = .results(plan)
|
||||||
|
} catch let error as SyncEngine.DuplicateScanError {
|
||||||
|
switch error {
|
||||||
|
case .notConnected:
|
||||||
|
phase = .failed(.other("iCloud isn't connected yet. Try again in a moment."))
|
||||||
|
case .unreadableFiles(let paths):
|
||||||
|
phase = .failed(.unreadableFiles(paths))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
phase = .failed(.other(error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDelete(_ plan: DuplicateCleanupPlan) async {
|
||||||
|
phase = .deleting
|
||||||
|
let result = await sync.performCleanup(plan)
|
||||||
|
phase = .done(CleanupSummary(
|
||||||
|
splitsDeleted: result.splitsDeleted,
|
||||||
|
workoutsDeleted: result.workoutsDeleted,
|
||||||
|
skipped: result.skipped
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,18 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
import IndieAbout
|
import IndieAbout
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(SyncEngine.self) private var sync
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Environment(AppServices.self) private var services
|
@Environment(AppServices.self) private var services
|
||||||
|
|
||||||
@Query(sort: \Split.order) private var splits: [Split]
|
|
||||||
|
|
||||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||||
@State private var showingAddSplitSheet = false
|
|
||||||
|
@State private var isRestoringSeeds = false
|
||||||
|
@State private var restoreSeedsMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -53,62 +51,50 @@ struct SettingsView: View {
|
|||||||
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
|
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Splits Section
|
// MARK: - Library Section
|
||||||
Section(header: Text("Splits")) {
|
Section(header: Text("Library")) {
|
||||||
if splits.isEmpty {
|
NavigationLink {
|
||||||
HStack {
|
SplitListView()
|
||||||
Spacer()
|
} label: {
|
||||||
VStack(spacing: 8) {
|
Label("Splits", systemImage: "dumbbell.fill")
|
||||||
Image(systemName: "dumbbell.fill")
|
}
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.secondary)
|
NavigationLink {
|
||||||
Text("No Splits Yet")
|
ExerciseLibraryView()
|
||||||
.font(.headline)
|
} label: {
|
||||||
.foregroundColor(.secondary)
|
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
|
||||||
Text("Create a split to organize your workout routine.")
|
}
|
||||||
.font(.caption)
|
}
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
// MARK: - Starter Splits Section
|
||||||
}
|
Section {
|
||||||
.padding(.vertical)
|
Button {
|
||||||
Spacer()
|
Task {
|
||||||
|
isRestoringSeeds = true
|
||||||
|
restoreSeedsMessage = nil
|
||||||
|
let n = await sync.restoreSeeds()
|
||||||
|
isRestoringSeeds = false
|
||||||
|
restoreSeedsMessage = n == 0
|
||||||
|
? "All starter splits are already present."
|
||||||
|
: "Restored \(n) starter split\(n == 1 ? "" : "s")."
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Restore Starter Splits", systemImage: "arrow.counterclockwise")
|
||||||
|
if isRestoringSeeds {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
|
||||||
|
} header: {
|
||||||
|
Text("Starter Splits")
|
||||||
|
} footer: {
|
||||||
|
if let restoreSeedsMessage {
|
||||||
|
Text(restoreSeedsMessage)
|
||||||
} else {
|
} else {
|
||||||
ForEach(splits) { split in
|
Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.")
|
||||||
NavigationLink {
|
|
||||||
SplitDetailView(split: split)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: split.systemImage)
|
|
||||||
.foregroundColor(Color.color(from: split.color))
|
|
||||||
.frame(width: 24)
|
|
||||||
Text(split.name)
|
|
||||||
Spacer()
|
|
||||||
Text("\(split.exercisesArray.count)")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showingAddSplitSheet = true
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text("Add Split")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "wand.and.sparkles")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text("Add Starter Splits")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +119,17 @@ struct SettingsView: View {
|
|||||||
Text("iCloud Sync")
|
Text("iCloud Sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Developer Section
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
DuplicateCleanupView()
|
||||||
|
} label: {
|
||||||
|
Label("Clean Up Duplicates", systemImage: "wrench.and.screwdriver")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Developer")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - About Section
|
// MARK: - About Section
|
||||||
Section {
|
Section {
|
||||||
IndieAbout(configuration: AppInfoConfiguration(
|
IndieAbout(configuration: AppInfoConfiguration(
|
||||||
@@ -144,9 +141,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.sheet(isPresented: $showingAddSplitSheet) {
|
|
||||||
SplitAddEditView(split: nil)
|
|
||||||
}
|
|
||||||
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||||
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||||
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
|
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Workouts
|
||||||
|
|
||||||
|
/// Locks the survivor rules of `DuplicateCleanupPlanner` — the safety-critical
|
||||||
|
/// part of the developer duplicate-cleanup tool. Referenced-split and seed
|
||||||
|
/// protection must always beat "earliest ULID wins," an in-progress workout must
|
||||||
|
/// never be touched, and the HealthKit-link holder must always survive (deleting
|
||||||
|
/// it would cascade-delete the real Health sample). Fixed 26-char ULID-like ids
|
||||||
|
/// keep every assertion deterministic.
|
||||||
|
struct DuplicateCleanupPlannerTests {
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
private func id(_ n: Int) -> String {
|
||||||
|
"01TESTID" + String(repeating: "0", count: 16) + String(format: "%02d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exercise(
|
||||||
|
name: String = "Bench Press", order: Int = 0, sets: Int = 3, reps: Int = 10,
|
||||||
|
weight: Int = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0
|
||||||
|
) -> ExerciseDocument {
|
||||||
|
ExerciseDocument(
|
||||||
|
id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, weight: weight,
|
||||||
|
loadType: loadType, durationSeconds: duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func split(
|
||||||
|
id splitID: String, name: String = "Push Day", exercises: [ExerciseDocument]? = nil,
|
||||||
|
createdAt: Date = Date(timeIntervalSince1970: 0)
|
||||||
|
) -> SplitDocument {
|
||||||
|
SplitDocument(
|
||||||
|
schemaVersion: SplitDocument.currentSchemaVersion, id: splitID, name: name, color: "indigo",
|
||||||
|
systemImage: "dumbbell.fill", order: 0, createdAt: createdAt, updatedAt: createdAt,
|
||||||
|
exercises: exercises ?? [exercise()], activityType: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(name: String = "Bench Press", order: Int = 0, date: Date = Date(timeIntervalSince1970: 0)) -> WorkoutLogDocument {
|
||||||
|
WorkoutLogDocument(
|
||||||
|
id: "LOG-\(name)-\(order)", exerciseName: name, order: order, sets: 3, reps: 10, weight: 100,
|
||||||
|
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
|
||||||
|
status: WorkoutStatus.completed.rawValue, notes: nil, date: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func workout(
|
||||||
|
id workoutID: String, splitID: String? = "SPLIT-1", splitName: String? = "Push Day",
|
||||||
|
start: Date, status: String = WorkoutStatus.completed.rawValue,
|
||||||
|
logs: [WorkoutLogDocument]? = nil, metrics: WorkoutMetrics? = nil
|
||||||
|
) -> WorkoutDocument {
|
||||||
|
WorkoutDocument(
|
||||||
|
schemaVersion: WorkoutDocument.currentSchemaVersion, id: workoutID, splitID: splitID,
|
||||||
|
splitName: splitName, start: start, end: nil, status: status, createdAt: start, updatedAt: start,
|
||||||
|
logs: logs ?? [log(date: start)], metrics: metrics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixed calendar day/time built via `Calendar.current`, so it agrees with
|
||||||
|
/// the planner's own `Calendar.current`-based day bucketing regardless of the
|
||||||
|
/// test runner's time zone.
|
||||||
|
private func fixedDate(day: Int = 15, hour: Int = 9) -> Date {
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.year = 2024
|
||||||
|
comps.month = 3
|
||||||
|
comps.day = day
|
||||||
|
comps.hour = hour
|
||||||
|
return Calendar.current.date(from: comps)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metrics(healthKitWorkoutUUID: String? = nil, recordedAt: Date = Date(timeIntervalSince1970: 0)) -> WorkoutMetrics {
|
||||||
|
WorkoutMetrics(
|
||||||
|
activeEnergyKcal: nil, avgHeartRate: nil, maxHeartRate: nil, minHeartRate: nil,
|
||||||
|
totalVolume: nil, hrZoneSeconds: nil, healthKitWorkoutUUID: healthKitWorkoutUUID,
|
||||||
|
source: .watch, recordedAt: recordedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Split survivor rules
|
||||||
|
|
||||||
|
@Test func identicalUnreferencedSplitsKeepEarliestULID() {
|
||||||
|
let earlier = split(id: id(1))
|
||||||
|
let later = split(id: id(2))
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [later, earlier], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.splitGroups.count == 1)
|
||||||
|
#expect(plan.splitGroups[0].keep.map(\.id) == [id(1)])
|
||||||
|
#expect(plan.splitGroups[0].delete.map(\.id) == [id(2)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Referenced-protection must override "earliest ULID wins": a later,
|
||||||
|
/// referenced split survives and the earlier, unreferenced duplicate is the
|
||||||
|
/// one deleted — the opposite of the no-protection tiebreak.
|
||||||
|
@Test func referencedProtectionOverridesEarliestWins() {
|
||||||
|
let earlier = split(id: id(1)) // unreferenced, would win the earliest-id tiebreak alone
|
||||||
|
let later = split(id: id(2)) // referenced by a workout
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [earlier, later], workouts: [], referencedSplitIDs: [id(2)], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.splitGroups.count == 1)
|
||||||
|
#expect(plan.splitGroups[0].keep.map(\.id) == [id(2)])
|
||||||
|
#expect(plan.splitGroups[0].delete.map(\.id) == [id(1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seedMemberNeverDeleted() throws {
|
||||||
|
let seedSplit = split(id: id(1))
|
||||||
|
let duplicate = split(id: id(2))
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [seedSplit, duplicate], workouts: [], referencedSplitIDs: [],
|
||||||
|
isSeed: { $0 == id(1) }
|
||||||
|
)
|
||||||
|
let group = try #require(plan.splitGroups.first)
|
||||||
|
#expect(group.keep.map(\.id) == [id(1)])
|
||||||
|
#expect(group.delete.map(\.id) == [id(2)])
|
||||||
|
#expect(!group.delete.contains { $0.id == id(1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func differingExerciseContentNotGrouped() {
|
||||||
|
let a = split(id: id(1), exercises: [exercise(weight: 100)])
|
||||||
|
let b = split(id: id(2), exercises: [exercise(weight: 105)])
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [a, b], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.splitGroups.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine comfort settings are real content: splits identical except for a
|
||||||
|
/// setting value — or for the nil (non-machine) vs. empty (machine, nothing
|
||||||
|
/// recorded) distinction — must not be judged duplicates.
|
||||||
|
@Test func differingMachineSettingsNotGrouped() {
|
||||||
|
var lowSeat = exercise()
|
||||||
|
lowSeat.machineSettings = [MachineSetting(name: "Seat Height", value: "4")]
|
||||||
|
var highSeat = exercise()
|
||||||
|
highSeat.machineSettings = [MachineSetting(name: "Seat Height", value: "5")]
|
||||||
|
var machineNothingRecorded = exercise()
|
||||||
|
machineNothingRecorded.machineSettings = []
|
||||||
|
|
||||||
|
let a = split(id: id(1), exercises: [lowSeat])
|
||||||
|
let b = split(id: id(2), exercises: [highSeat])
|
||||||
|
let c = split(id: id(3), exercises: [machineNothingRecorded])
|
||||||
|
let d = split(id: id(4)) // machineSettings nil — not a machine exercise
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [a, b, c, d], workouts: [], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.splitGroups.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout grouping / survivor rules
|
||||||
|
|
||||||
|
@Test func sameDayWorkoutsDifferingOnlyInIDsAndTimestampsAreGrouped() {
|
||||||
|
let day = fixedDate(hour: 9)
|
||||||
|
let sameDayLater = fixedDate(hour: 20)
|
||||||
|
let w1 = workout(id: id(1), start: day)
|
||||||
|
let w2 = workout(id: id(2), start: sameDayLater)
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.workoutGroups.count == 1)
|
||||||
|
#expect(plan.workoutGroups[0].keep.id == id(1))
|
||||||
|
#expect(plan.workoutGroups[0].delete.map(\.id) == [id(2)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func differentDaysNotGrouped() {
|
||||||
|
let w1 = workout(id: id(1), start: fixedDate(day: 15))
|
||||||
|
let w2 = workout(id: id(2), start: fixedDate(day: 16))
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.workoutGroups.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inProgressMemberDropsEntireGroup() {
|
||||||
|
let start = fixedDate()
|
||||||
|
let w1 = workout(id: id(1), start: start, status: WorkoutStatus.inProgress.rawValue)
|
||||||
|
let w2 = workout(id: id(2), start: start, status: WorkoutStatus.inProgress.rawValue)
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [], workouts: [w1, w2], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
#expect(plan.workoutGroups.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HealthKit-link holder must survive even though the other copy has the
|
||||||
|
/// lexicographically smaller (earlier) id — deleting the link holder would
|
||||||
|
/// cascade-delete the real Health sample.
|
||||||
|
@Test func healthKitLinkHolderSurvivesOverEarlierULID() throws {
|
||||||
|
let start = fixedDate()
|
||||||
|
let earlierNoLink = workout(id: id(1), start: start, metrics: nil)
|
||||||
|
let laterWithLink = workout(id: id(2), start: start, metrics: metrics(healthKitWorkoutUUID: "HK-UUID"))
|
||||||
|
let plan = DuplicateCleanupPlanner.plan(
|
||||||
|
splits: [], workouts: [earlierNoLink, laterWithLink], referencedSplitIDs: [], isSeed: { _ in false }
|
||||||
|
)
|
||||||
|
let group = try #require(plan.workoutGroups.first)
|
||||||
|
#expect(group.keep.id == id(2))
|
||||||
|
#expect(group.delete.map(\.id) == [id(1)])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import IndieSync
|
||||||
|
@testable import Workouts
|
||||||
|
|
||||||
|
/// Locks the decision table of `SeedReconcilePlanner` — the safety-critical logic that
|
||||||
|
/// upgrades stale starter-seed revisions and adds newly-shipped seeds on an existing
|
||||||
|
/// install without ever duplicating a user's split, resurrecting a deleted seed, or
|
||||||
|
/// downgrade-rewriting a newer app version's file. All fixtures are plain structs so
|
||||||
|
/// every assertion is a pure, deterministic function of the inputs.
|
||||||
|
struct SeedReconcilePlannerTests {
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
private func exercise(
|
||||||
|
name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Int = 100
|
||||||
|
) -> ExerciseDocument {
|
||||||
|
ExerciseDocument(
|
||||||
|
id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps,
|
||||||
|
weight: weight, loadType: LoadType.weight.rawValue, durationSeconds: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A seed-shaped split at the current schema version, with a fixed frozen date so
|
||||||
|
/// two builds of "the same" content compare equal.
|
||||||
|
private func seed(
|
||||||
|
id: String = "01DXF6DT0038BDC2WC3EVX8ZJ5",
|
||||||
|
name: String = "Upper Body",
|
||||||
|
schemaVersion: Int = SplitDocument.currentSchemaVersion,
|
||||||
|
exercises: [ExerciseDocument]? = nil
|
||||||
|
) -> SplitDocument {
|
||||||
|
SplitDocument(
|
||||||
|
schemaVersion: schemaVersion, id: id, name: name, color: "indigo",
|
||||||
|
systemImage: "dumbbell.fill", order: 0,
|
||||||
|
createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0),
|
||||||
|
exercises: exercises ?? [exercise(name: "Bench Press")], activityType: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live file present
|
||||||
|
|
||||||
|
/// An older app version wrote this seed at schemaVersion 1 (and, on disk, carried
|
||||||
|
/// extra now-removed keys — which decode into the current struct as simply absent).
|
||||||
|
/// The content is otherwise identical, so normalizing the version stamp must make it
|
||||||
|
/// read as up to date: no churn, no rewrite.
|
||||||
|
@Test func semanticEqualitySkipsAcrossSchemaVersions() {
|
||||||
|
let bundle = seed() // current schemaVersion
|
||||||
|
var live = bundle
|
||||||
|
live.schemaVersion = 1 // older wrapper, same content
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.upToDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundle seed gained an exercise since this install wrote its file — a real
|
||||||
|
/// content change must upgrade (overwrite with current bundle bytes), even though
|
||||||
|
/// the live file is a lower schema version.
|
||||||
|
@Test func contentChangeUpgrades() {
|
||||||
|
let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)])
|
||||||
|
var live = seed(exercises: [exercise(name: "Bench Press")]) // one fewer exercise
|
||||||
|
live.schemaVersion = 1
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A weight tweak alone is enough of a content change to upgrade.
|
||||||
|
@Test func exerciseWeightChangeUpgrades() {
|
||||||
|
let bundle = seed(exercises: [exercise(name: "Bench Press", weight: 135)])
|
||||||
|
let live = seed(exercises: [exercise(name: "Bench Press", weight: 115)])
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .upgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live file written by a NEWER app version (schemaVersion above current) is
|
||||||
|
/// quarantined — never downgrade-rewrite it, even though it differs.
|
||||||
|
@Test func newerSchemaFileIsQuarantined() {
|
||||||
|
let bundle = seed()
|
||||||
|
var live = seed(exercises: [exercise(name: "Something New")])
|
||||||
|
live.schemaVersion = SplitDocument.currentSchemaVersion + 1
|
||||||
|
#expect(!live.isReadable) // guards the premise
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.quarantined))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - No live file
|
||||||
|
|
||||||
|
/// No live file but a stub exists → the user deleted this seed. The veto stands.
|
||||||
|
@Test func stubVetoesResurrection() {
|
||||||
|
let bundle = seed()
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: []) == .skip(.vetoed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No live file, no stub, but a live split (a legacy runtime-built or user-cloned
|
||||||
|
/// "Upper Body") already uses the seed's name → writing it would duplicate by name.
|
||||||
|
@Test func nameCollisionSkips() {
|
||||||
|
let bundle = seed(name: "Upper Body")
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .skip(.nameCollision))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No live file, no stub, no same-name split → a genuinely missing seed (newly
|
||||||
|
/// shipped, or a pre-fixed-ULID install) gets written.
|
||||||
|
@Test func missingSeedIsWritten() {
|
||||||
|
let bundle = seed(name: "Bodyweight Core")
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: false)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body", "Core"]) == .write)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stub present but the same name also live: the veto is checked first, so it
|
||||||
|
/// still skips (as vetoed) rather than as a name collision — either way, no write.
|
||||||
|
@Test func stubTakesPrecedenceOverNameCollision() {
|
||||||
|
let bundle = seed(name: "Upper Body")
|
||||||
|
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: nil, hasStub: true)
|
||||||
|
|
||||||
|
#expect(SeedReconcilePlanner.decision(for: input, liveSplitNames: ["Upper Body"]) == .skip(.vetoed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore
|
||||||
|
|
||||||
|
/// Restore rewrites only a seed that is both absent and free of a name collision;
|
||||||
|
/// the presence of a veto stub is irrelevant (restore lifts it).
|
||||||
|
@Test func shouldRestoreOnlyWhenAbsentAndNameFree() {
|
||||||
|
// Absent, name free → restore (whether or not a stub exists).
|
||||||
|
#expect(SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: []))
|
||||||
|
// Live file already present → reconcile handles any upgrade; restore leaves it.
|
||||||
|
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: true, seedName: "Upper Body", liveSplitNames: []))
|
||||||
|
// A live same-name split exists → don't duplicate it.
|
||||||
|
#expect(!SeedReconcilePlanner.shouldRestore(hasLiveFile: false, seedName: "Upper Body", liveSplitNames: ["Upper Body"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user