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:
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
|
||||
/// The bundled immutable starter-split library. Each seed ships as byte-canonical
|
||||
/// `SplitDocument` JSON in `Resources/StarterSplits/*.split.json`, keyed by a fixed
|
||||
/// ULID (the shared `01DXF6DT00` prefix, minted from a frozen 2020 timestamp) so
|
||||
/// every install and device agrees on the same identity.
|
||||
///
|
||||
/// Seeds are never mutated in place: editing one clones it to a fresh random ULID
|
||||
/// and soft-deletes the seed (see `SyncEngine.save(split:)`). The seed's tombstone
|
||||
/// is exempt from pruning, so it vetoes resurrection forever; the app bundle — not
|
||||
/// the stub — is the canonical restore source, since the seed content is immutable.
|
||||
enum SeedLibrary {
|
||||
/// One bundled seed: its fixed id, decoded document, and the verbatim bundle
|
||||
/// bytes. The bytes are written to iCloud unchanged so the same seed is
|
||||
/// byte-identical on every device — a same-path conflict between two devices
|
||||
/// seeding at once is then semantically empty (both wrote the same bytes).
|
||||
struct Seed: Sendable {
|
||||
let id: String
|
||||
let doc: SplitDocument
|
||||
let data: Data
|
||||
}
|
||||
|
||||
/// Resolves the enclosing bundle in both the app and a hosted XCTest bundle
|
||||
/// (whose `Bundle.main` is the test runner, not the app), where the flattened
|
||||
/// resources actually live.
|
||||
private final class BundleToken {}
|
||||
|
||||
/// Every bundled seed, decoded once and cached, in display order.
|
||||
static let seeds: [Seed] = {
|
||||
let bundle = Bundle(for: BundleToken.self)
|
||||
// XcodeGen flattens resource groups, so the files land in the bundle root as
|
||||
// "<Name>.split.json" — enumerate all json and filter on the compound suffix.
|
||||
let urls = (bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? [])
|
||||
.filter { $0.lastPathComponent.hasSuffix(".split.json") }
|
||||
let loaded: [Seed] = urls.compactMap { url in
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let doc = try? DocumentCoder.decode(SplitDocument.self, from: data)
|
||||
else { return nil }
|
||||
return Seed(id: doc.id, doc: doc, data: data)
|
||||
}
|
||||
return loaded.sorted { $0.doc.order < $1.doc.order }
|
||||
}()
|
||||
|
||||
static let seedIDs: Set<String> = Set(seeds.map(\.id))
|
||||
|
||||
static func isSeed(id: String) -> Bool { seedIDs.contains(id) }
|
||||
|
||||
static func seed(id: String) -> Seed? { seeds.first { $0.id == id } }
|
||||
|
||||
/// True when `doc` is unchanged from its pristine seed in every field except
|
||||
/// `updatedAt`. The edit sheets stamp `updatedAt` on every Save, so a no-op save
|
||||
/// would otherwise read as an edit and fork the seed; normalizing both timestamps
|
||||
/// to a common value before the `==` isolates real changes. A non-seed id has no
|
||||
/// seed to compare against — treated as pristine (callers gate on `isSeed` first).
|
||||
static func isPristine(_ doc: SplitDocument) -> Bool {
|
||||
guard let seed = seed(id: doc.id) else { return true }
|
||||
var edited = doc
|
||||
var pristine = seed.doc
|
||||
let common = Date(timeIntervalSince1970: 0)
|
||||
edited.updatedAt = common
|
||||
pristine.updatedAt = common
|
||||
return edited == pristine
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,32 @@
|
||||
import IndieSync
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Builds the bundled machine-based starter routine (Upper Body / Core / Lower
|
||||
/// Body). Written on demand (never auto-seeded) through the SyncEngine — an empty
|
||||
/// cache at launch can't be told apart from an iCloud library that simply hasn't
|
||||
/// downloaded yet.
|
||||
/// 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 {
|
||||
/// One starter exercise: name plus its default starting weight (lbs).
|
||||
private struct SeedExercise {
|
||||
let name: String
|
||||
let weight: Int
|
||||
}
|
||||
|
||||
/// One starter split: visual theme, activity type, plus its ordered exercises.
|
||||
private struct SeedSplit {
|
||||
let name: String
|
||||
let color: String
|
||||
let icon: String
|
||||
let activity: WorkoutActivityType
|
||||
let exercises: [SeedExercise]
|
||||
}
|
||||
|
||||
/// Sets/reps shared by every starter exercise.
|
||||
private static let defaultSets = 4
|
||||
private static let defaultReps = 10
|
||||
|
||||
/// Starter splits in display order, with sensible machine starting weights.
|
||||
/// Users adjust weights from the exercise screen.
|
||||
private static let starterSplits: [SeedSplit] = [
|
||||
SeedSplit(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: .traditionalStrength, exercises: [
|
||||
SeedExercise(name: "Lat Pull Down", weight: 110),
|
||||
SeedExercise(name: "Tricep Press", weight: 100),
|
||||
SeedExercise(name: "Chest Press", weight: 40),
|
||||
SeedExercise(name: "Seated Row", weight: 90),
|
||||
]),
|
||||
SeedSplit(name: "Core", color: "orange", icon: "figure.core.training", activity: .coreTraining, exercises: [
|
||||
SeedExercise(name: "Abdominal", weight: 0),
|
||||
SeedExercise(name: "Rotary", weight: 0),
|
||||
]),
|
||||
SeedSplit(name: "Lower Body", color: "green", icon: "figure.run", activity: .traditionalStrength, exercises: [
|
||||
SeedExercise(name: "Abductor", weight: 140),
|
||||
SeedExercise(name: "Adductor", weight: 140),
|
||||
SeedExercise(name: "Leg Press", weight: 160),
|
||||
SeedExercise(name: "Leg Curl", weight: 70),
|
||||
]),
|
||||
]
|
||||
|
||||
/// Builds the default split documents (fresh ULIDs each call).
|
||||
static func defaultSplitDocuments() -> [SplitDocument] {
|
||||
starterSplits.enumerated().map { order, split in
|
||||
let exercises = split.exercises.enumerated().map { idx, item in
|
||||
ExerciseDocument(
|
||||
id: ULID.make(), name: item.name, order: idx,
|
||||
sets: defaultSets, reps: defaultReps, weight: item.weight,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
)
|
||||
}
|
||||
return SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(),
|
||||
name: split.name, color: split.color, systemImage: split.icon, order: order,
|
||||
createdAt: Date(), updatedAt: Date(), exercises: exercises,
|
||||
activityType: split.activity.rawValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes any starter splits whose name doesn't already exist, appended after
|
||||
/// existing splits. Idempotent against double-taps / partial prior seeds.
|
||||
/// 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))
|
||||
let base = existing.count
|
||||
|
||||
let fresh = defaultSplitDocuments().filter { !existingNames.contains($0.name) }
|
||||
for (offset, var doc) in fresh.enumerated() {
|
||||
doc.order = base + offset
|
||||
await sync.save(split: doc)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user