Editing a workout's start date now removes the file at its old month bucket so the record no longer duplicates on the next reconcile. Seed reconcile re-checks the tombstone veto before overwriting an upgraded seed. The watch applies authoritative-empty pushes so remote deletes prune, and a re-saved finished workout keeps its original end time. Adds unit tests for the mappers, path bucketing, and status machine. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
143 lines
5.6 KiB
Swift
143 lines
5.6 KiB
Swift
// Generates the bundled starter-split JSON resources for Workouts.
|
|
// Deterministic: fixed ULID timestamp (2020-01-01T00:00:00Z) + SHA256-of-label
|
|
// randomness, fixed document timestamps, encoder matching DocumentCoder
|
|
// (prettyPrinted + sortedKeys + iso8601). Re-running always emits identical bytes.
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
// MARK: - ULID (Crockford base32, 48-bit ms timestamp + 80 deterministic bits)
|
|
|
|
let crockford = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
|
|
|
func deterministicULID(label: String) -> String {
|
|
let timestampMS: UInt64 = 1_577_836_800_000 // 2020-01-01T00:00:00Z
|
|
var bytes = [UInt8]()
|
|
for shift in stride(from: 40, through: 0, by: -8) {
|
|
bytes.append(UInt8((timestampMS >> UInt64(shift)) & 0xFF))
|
|
}
|
|
let digest = SHA256.hash(data: Data(label.utf8))
|
|
bytes.append(contentsOf: Array(digest).prefix(10))
|
|
precondition(bytes.count == 16)
|
|
|
|
// Encode 128 bits as 26 Crockford base32 chars: big-int division by 32, 26 digits.
|
|
var chars = [Character]()
|
|
var num = bytes
|
|
for _ in 0..<26 {
|
|
var remainder = 0
|
|
var quotient = [UInt8]()
|
|
for byte in num {
|
|
let acc = remainder * 256 + Int(byte)
|
|
quotient.append(UInt8(acc / 32))
|
|
remainder = acc % 32
|
|
}
|
|
chars.append(crockford[remainder])
|
|
num = quotient
|
|
}
|
|
return String(chars.reversed())
|
|
}
|
|
|
|
// MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift)
|
|
|
|
struct MachineSetting: Codable {
|
|
var name: String
|
|
var value: String
|
|
}
|
|
|
|
struct ExerciseDocument: Codable {
|
|
var id: String
|
|
var name: String
|
|
var order: Int
|
|
var sets: Int
|
|
var reps: Int
|
|
var weight: Int
|
|
var loadType: Int
|
|
var durationSeconds: Int
|
|
var machineSettings: [MachineSetting]? = nil
|
|
}
|
|
|
|
struct SplitDocument: Codable {
|
|
var schemaVersion: Int
|
|
var id: String
|
|
var name: String
|
|
var color: String
|
|
var systemImage: String
|
|
var order: Int
|
|
var createdAt: Date
|
|
var updatedAt: Date
|
|
var exercises: [ExerciseDocument]
|
|
var activityType: Int?
|
|
}
|
|
|
|
// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json)
|
|
|
|
// `machine: true` emits `machineSettings: []` — marks the exercise machine-based
|
|
// (empty = nothing recorded yet), which lights up the machine-settings UI without
|
|
// the user having to flip the editor toggle first.
|
|
struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var machine = false }
|
|
struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] }
|
|
|
|
let seeds: [Sp] = [
|
|
Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [
|
|
Ex(name: "Lat Pull Down", weight: 110, machine: true),
|
|
Ex(name: "Seated Row", weight: 90, machine: true),
|
|
Ex(name: "Shoulder Press", weight: 40, machine: true),
|
|
Ex(name: "Chest Press", weight: 40, machine: true),
|
|
Ex(name: "Tricep Press", weight: 100, machine: true),
|
|
Ex(name: "Arm Curl", weight: 40, machine: true),
|
|
]),
|
|
Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [
|
|
Ex(name: "Abdominal", weight: 40, machine: true),
|
|
Ex(name: "Rotary", weight: 40, machine: true),
|
|
]),
|
|
Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [
|
|
Ex(name: "Leg Press", weight: 140, machine: true),
|
|
Ex(name: "Leg Curl", weight: 40, machine: true),
|
|
Ex(name: "Leg Extension", weight: 80, machine: true),
|
|
Ex(name: "Abductor", weight: 130, machine: true),
|
|
Ex(name: "Adductor", weight: 130, machine: true),
|
|
Ex(name: "Calfs", weight: 100, machine: true),
|
|
]),
|
|
Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [
|
|
Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0),
|
|
Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0),
|
|
Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0),
|
|
Ex(name: "Plank", sets: 3, reps: 0, load: 2, dur: 45),
|
|
Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30),
|
|
Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30),
|
|
Ex(name: "Leg Raises", sets: 3, reps: 12, load: 0),
|
|
Ex(name: "Crunch", sets: 3, reps: 12, load: 0),
|
|
Ex(name: "Reverse Crunch", sets: 3, reps: 12, load: 0),
|
|
]),
|
|
]
|
|
|
|
// MARK: - Emit
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
|
|
let fixedDate = ISO8601DateFormatter().date(from: "2020-01-01T00:00:00Z")!
|
|
let outDir = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
|
try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
|
|
|
for (order, sp) in seeds.enumerated() {
|
|
let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-")
|
|
let doc = SplitDocument(
|
|
schemaVersion: 2,
|
|
id: deterministicULID(label: "starter.\(slug)"),
|
|
name: sp.name, color: sp.color, systemImage: sp.icon, order: order,
|
|
createdAt: fixedDate, updatedAt: fixedDate,
|
|
exercises: sp.ex.enumerated().map { idx, e in
|
|
ExerciseDocument(
|
|
id: deterministicULID(label: "starter.\(slug).ex\(idx)"),
|
|
name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight,
|
|
loadType: e.load, durationSeconds: e.dur,
|
|
machineSettings: e.machine ? [] : nil)
|
|
},
|
|
activityType: sp.activity)
|
|
let data = try encoder.encode(doc)
|
|
let file = outDir.appendingPathComponent("\(sp.name).split.json")
|
|
try data.write(to: file)
|
|
print("\(doc.id) \(sp.name) (\(sp.ex.count) exercises)")
|
|
}
|