Seed starter splits deterministically with immutable clone-on-edit seeds

Starter splits ship as byte-canonical SplitDocument JSON with fixed
ULIDs (Workouts/Resources/StarterSplits, regenerated by
Scripts/generate_starter_splits.swift) and auto-seed after connect into
a verifiably empty container, re-checked after a settle delay — wrong
guesses are harmless because identical bytes make same-path conflicts
empty and tombstones reap resurrected seeds. Seeds are immutable:
SyncEngine.save(split:) forks an edited seed to a fresh ULID and
soft-deletes the original, whose stub is exempt from pruning
(IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever;
split views resolve by id through a redirect map to follow the swap.
Add Starter Splits in Settings restores deleted seeds by lifting the
veto stub and rewriting the bundle bytes.

Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted
workout resurrected when a stale watch resent it) and reaps a live file
immediately when its tombstone arrives.

SplitDetailView also picks up the category-grouped exercise sections
from the exercise-category work.
This commit is contained in:
2026-07-06 01:16:05 -04:00
parent 7274f155e9
commit 936a585ece
16 changed files with 1088 additions and 242 deletions
+40 -10
View File
@@ -14,8 +14,13 @@ import SwiftData
struct ExerciseListView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
var split: Split
// Resolve the split by id, not a captured entity: editing a seed's exercise from
// here clones the seed (new identity, old entity deleted), which would dangle a
// stored `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@@ -31,11 +36,41 @@ struct ExerciseListView: View {
@State private var showingActivePrompt = false
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(split: Split) {
_splitID = State(initialValue: split.id)
}
private var split: Split? {
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
}
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
var body: some View {
Group {
if let split {
content(for: split)
} else {
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
// Navigate to the workout log once the entity appears in the cache.
.navigationDestination(item: $resolvedWorkout) { workout in
WorkoutLogListView(workout: workout)
}
// Poll for the entity after we write the document.
.onChange(of: pendingWorkoutID) { _, id in
guard let id else { return }
pollForWorkout(id: id)
}
}
@ViewBuilder
private func content(for split: Split) -> some View {
Form {
let sortedExercises = split.exercisesArray
@@ -83,15 +118,6 @@ struct ExerciseListView: View {
.disabled(split.exercisesArray.isEmpty)
}
}
// Navigate to the workout log once the entity appears in the cache.
.navigationDestination(item: $resolvedWorkout) { workout in
WorkoutLogListView(workout: workout)
}
// Poll for the entity after we write the document.
.onChange(of: pendingWorkoutID) { _, id in
guard let id else { return }
pollForWorkout(id: id)
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
@@ -183,6 +209,7 @@ struct ExerciseListView: View {
}
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let split else { return }
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
@@ -196,6 +223,7 @@ struct ExerciseListView: View {
}
private func start() {
guard let split else { return }
let startDate = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument(
@@ -226,6 +254,7 @@ struct ExerciseListView: View {
}
private func addExercises(names: [String]) {
guard let split else { return }
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
@@ -246,6 +275,7 @@ struct ExerciseListView: View {
}
private func deleteExercise(_ exercise: Exercise) {
guard let split else { return }
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {