Queue document writes durably and surface sync trouble in the UI

iCloud Drive writes now flow through a persistent WriteBacklog sidecar
(drained with backoff, flushed on backgrounding, wiped with the cache on
account change), so a save can never be lost to a transient coordinator
error. A status banner on the workout list surfaces stuck syncing.
Also: the split picker gains a Recent section with day labels, split
rows fold SplitItem into SplitListView, and list rows dim the multiply
sign in sets-by-reps.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 12:48:12 -04:00
parent 495fce1e5a
commit c05e83cff7
13 changed files with 1175 additions and 144 deletions
@@ -461,7 +461,6 @@ private struct WorkoutLogRow: View {
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
/// strength and the × dimmed, so the counts read at a glance.
private var setsAndReps: Text {
let times = Text(" × ").foregroundStyle(.secondary)
let count: String
if loadType == .duration {
let mins = log.durationSeconds / 60
@@ -476,7 +475,7 @@ private struct WorkoutLogRow: View {
} else {
count = "\(log.reps)"
}
return Text("\(log.sets)") + times + Text(count)
return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
}
private var showsWeight: Bool { loadType == .weight }
@@ -61,6 +61,11 @@ struct WorkoutLogsView: View {
}
}
.navigationTitle("Workout Logs")
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
// On the root screen so a stalled save is visible where editing happens.
.safeAreaInset(edge: .bottom) {
SyncStatusBanner()
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
@@ -152,24 +157,70 @@ struct SplitPickerSheet: View {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
/// The splits most recently trained, newest first, each with the start date
/// of its latest workout derived from the workout log (`workouts` is
/// already sorted by start descending). Each workout's `splitID` follows the
/// seed clone-on-edit redirect so a workout started from a since-forked seed
/// still credits the live clone. Deduped, capped, and limited to splits that
/// still exist.
private var recentSplits: [(split: Split, lastStart: Date)] {
var seen = Set<String>()
var recents: [(split: Split, lastStart: Date)] = []
for workout in workouts {
guard let splitID = workout.splitID else { continue }
let liveID = sync.currentSplitID(for: splitID)
guard !seen.contains(liveID) else { continue }
seen.insert(liveID)
if let split = splits.first(where: { $0.id == liveID }) {
recents.append((split, workout.start))
if recents.count == 3 { break }
}
}
return recents
}
/// One selectable split row, shared by the Recent and All Splits sections.
/// Recent rows pass `lastTrained` to show how long ago the split was run.
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
Button {
confirmAndStart(with: split)
} label: {
HStack {
Image(systemName: split.systemImage)
.foregroundColor(Color.color(from: split.color))
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(split.name)
if let lastTrained {
Text(lastTrained.daysAgoLabel())
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
var body: some View {
NavigationStack {
List {
ForEach(splits) { split in
Button {
confirmAndStart(with: split)
} label: {
HStack {
Image(systemName: split.systemImage)
.foregroundColor(Color.color(from: split.color))
Text(split.name)
Spacer()
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundColor(.secondary)
if !recentSplits.isEmpty {
Section("Recent") {
ForEach(recentSplits, id: \.split.id) { recent in
splitRow(recent.split, lastTrained: recent.lastStart)
}
}
}
Section(recentSplits.isEmpty ? "" : "All Splits") {
ForEach(splits) { split in
splitRow(split)
}
}
}
.navigationTitle("Select a Split")
.toolbar {