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
+256 -49
View File
@@ -11,12 +11,16 @@ enum ICloudStatus: Equatable {
}
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth: every save/delete writes the file first, then
/// 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 sole source of truth. Every save/delete mirrors 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) and queues the file write in a per-document
/// slot backlog (`WriteBacklog`), drained serially with backoff. A failed write
/// therefore never silently evaporates an edit: it stays in its slot, the cache
/// keeps showing it, and `writeQueueState` surfaces the stall to the UI.
/// The observer and the connect-time reconcile re-apply idempotently and remain
/// the sole channel for *remote* changes.
/// the sole channel for *remote* changes; both skip documents superseded by a
/// queued-but-unwritten local change.
@Observable
@MainActor
final class SyncEngine {
@@ -42,6 +46,12 @@ final class SyncEngine {
/// next successful write or full reconcile.
private(set) var lastSyncError: String?
/// Rollup of the write backlog for the UI banner tiers (see `WriteBacklog`).
private(set) var writeQueueState: WriteQueueState = .idle
/// Number of queued-but-unwritten document writes.
var pendingWriteCount: Int { backlog.count }
/// 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
/// notifies on change); not persisted durability comes from `repointWorkouts`,
@@ -71,6 +81,9 @@ final class SyncEngine {
private var monitor: MetadataObserver?
private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0
private var backlog: WriteBacklog
private let backlogURL: URL
private var drainTask: Task<Void, Never>?
/// How long `connect()` keeps polling for a still-provisioning iCloud
/// container before falling to the end-of-the-line gate. Deliberately long
@@ -81,8 +94,17 @@ final class SyncEngine {
private var context: ModelContext { modelContainer.mainContext }
init(container: ModelContainer) {
init(container: ModelContainer, backlogURL: URL = WorkoutsModelContainer.pendingWritesURL) {
self.modelContainer = container
self.backlogURL = backlogURL
// Writes queued in a previous run survive relaunch; anything stuck past
// the TTL is dropped rather than drained stale into a container that has
// moved on. (An account change wipes the sidecar file before this load
// see `WorkoutsModelContainer.wipeIfAccountChanged`.)
var loaded = WriteBacklogFile.load(from: backlogURL)
loaded.pruneExpired(now: Date())
self.backlog = loaded
refreshQueueState()
}
// MARK: - Connection (deferred, patient)
@@ -165,6 +187,10 @@ final class SyncEngine {
log.info("connect[\(attempt)]: directories ready → available")
WorkoutsModelContainer.persistCurrentIdentityToken()
// Deliver any backlog surviving from a previous run before reconcile
// lists the tree reconcile skips pending ids anyway, but the sooner
// queued edits hit disk the smaller that window is.
kickDrain()
await reconcile()
startMonitoring(documentsURL: store.rootURL)
cleanupOldStubs()
@@ -204,7 +230,14 @@ final class SyncEngine {
monitorTask = nil
monitor?.stop()
monitor = nil
log.info("restore: began — metadata observer suspended")
// Queued writes reference pre-restore state; draining them into the
// restored tree would corrupt it with edits the user chose to roll back.
drainTask?.cancel()
drainTask = nil
backlog.removeAll()
persistBacklog()
refreshQueueState()
log.info("restore: began — metadata observer suspended, write backlog cleared")
}
/// Resume live file-watching after a restore. Starts a FRESH metadata observer
@@ -257,8 +290,8 @@ final class SyncEngine {
onCacheChanged?()
}
/// Apply a workout received from the watch `save(workout:)` writes the file
/// and mirrors it into the cache, same as a local edit.
/// Apply a workout received from the watch `save(workout:)` mirrors it into
/// the cache and queues the file write, same as a local edit.
func ingestFromWatch(_ doc: WorkoutDocument) async {
// 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
@@ -268,18 +301,50 @@ final class SyncEngine {
onCacheChanged?()
return
}
// A queued-but-unwritten delete is the same veto its stub just hasn't
// landed on disk yet.
if case .delete = backlog.pendingWrite(for: doc.id)?.payload {
onCacheChanged?()
return
}
// `updatedAt` intake gate: the watch mixes `sendMessage` with a queued
// `transferUserInfo` fallback, which are unordered a failed-then-queued
// older edit can arrive after a newer one. Accept only what's strictly
// newer than the cache; the cache mirrors every accepted write
// immediately, so it's always at least as new as any pending slot.
// Strictly older means the watch is behind re-push authoritative state
// so it corrects. Equal is the duplicate/echo case ignore silently.
//
// KNOWN LIMIT (deliberate, revisit if users report vanished sets): this
// gate arbitrates *timestamps*, not *content*. A doc edited on a stale
// watch snapshot carries a fresh stamp and replaces the whole workout
// concurrent edits inside one push round-trip (or a disconnection) lose
// one side wholesale. The watch-side absorb (WorkoutLogListView's
// onChange re-seed) narrows the stale window to one push latency; the
// durable fix is per-log merge: give WorkoutLogDocument its own optional
// `updatedAt` (same additive pattern as startedAt/completedAt), merge
// incoming docs log-by-log (newer log wins per id) instead of replacing,
// and carry log add/remove as explicit intents so an absent log is never
// ambiguous between "deleted" and "not seen yet". That makes edits to
// different logs commute, so delivery order and offline gaps stop
// mattering entirely.
if let cached = CacheMapper.fetchWorkout(id: doc.id, in: context) {
if doc.updatedAt < cached.updatedAt {
onCacheChanged?()
return
}
if doc.updatedAt == cached.updatedAt { return }
}
await save(workout: doc)
}
// MARK: - Public CRUD (write path: files only)
// MARK: - Public CRUD (mirror-first: cache now, file via the write queue)
/// Returns the *effective* id of the split that was written normally `doc.id`,
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
/// via `currentSplitID(for:)`.
@discardableResult
func save(split doc: SplitDocument) async -> String {
guard let store else { return doc.id }
// Seeds are immutable: a real edit forks the seed into a user-owned split and
// soft-deletes the original, keeping the curated seed intact and restorable. A
// pristine (no-op) save must NOT fork the edit sheets stamp `updatedAt`
@@ -289,14 +354,9 @@ final class SyncEngine {
return await cloneSeedOnEdit(doc)
}
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
lastSyncError = nil
} catch {
report("Failed to save split", error)
}
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(.split(doc), timestamp: doc.updatedAt)
return doc.id
}
@@ -377,41 +437,33 @@ final class SyncEngine {
}
func save(workout doc: WorkoutDocument) async {
guard let store else { return }
// The month bucket in a workout's path derives from `start`, so editing the
// start date (or a device in a different time zone) can move the file to a new
// path. Capture the previously-written path before the upsert overwrites it, so
// we can remove the stale file below otherwise the same id would live at two
// paths and the old copy would re-import on the next reconcile.
// path. Capture the previously-written path before the upsert overwrites it
// the queued write removes it after landing, otherwise the same id would live
// at two paths and the old copy would re-import on the next reconcile.
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
// Same id, new path: drop the orphaned file at the old bucket. The id lives
// on at the new path, so this is a plain removal no tombstone (a tombstone
// would veto the record that just moved). Phone stays the sole writer.
if let previousPath, previousPath != doc.relativePath {
try? await store.remove(at: previousPath)
}
saveCacheAndNotify()
lastSyncError = nil
} catch {
report("Failed to save workout", error)
}
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(
.workout(doc),
timestamp: doc.updatedAt,
stalePath: previousPath != doc.relativePath ? previousPath : nil
)
}
func delete(split: Split) async {
let id = split.id, livePath = split.jsonRelativePath
await softDelete(id: id, kind: "split", livePath: livePath)
deleteCachedEntity(id: id)
saveCacheAndNotify()
enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date())
}
func delete(workout: Workout) async {
let id = workout.id, livePath = workout.jsonRelativePath
await softDelete(id: id, kind: "workout", livePath: livePath)
deleteCachedEntity(id: id)
saveCacheAndNotify()
enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date())
}
/// Persist pending cache mutations and fan out the change notification the
@@ -421,8 +473,10 @@ final class SyncEngine {
onCacheChanged?()
}
/// Writes a tombstone stub then removes the live file. Other devices learn of
/// the delete via the stub even if they were offline for the file removal.
/// Writes a tombstone stub then removes the live file, immediately and outside
/// the write queue. Used only by the multi-step flows that need their file ops
/// ordered within a larger transaction (`cloneSeedOnEdit`, `performCleanup`);
/// plain user deletes go through the queue as `.delete` payloads.
private func softDelete(id: String, kind: String, livePath: String) async {
guard let store, let tombstones else { return }
do {
@@ -434,6 +488,145 @@ final class SyncEngine {
}
}
// MARK: - Write queue
/// Queue a file write whose cache mirror has already been applied, persist the
/// backlog, and make sure the drain loop is running. The per-id slot keeps only
/// the newest version (`WriteBacklog.enqueue` is newer-wins on `timestamp`).
private func enqueueWrite(_ payload: PendingWrite.Payload, timestamp: Date, stalePath: String? = nil) {
var write = PendingWrite(payload: payload, timestamp: timestamp, enqueuedAt: Date())
if let stalePath { write.stalePaths.insert(stalePath) }
backlog.enqueue(write)
persistBacklog()
refreshQueueState()
kickDrain()
}
private func kickDrain() {
guard drainTask == nil, store != nil, !isRestoring else { return }
drainTask = Task { [weak self] in
await self?.drainBacklog()
guard let self else { return }
self.drainTask = nil
// A write enqueued in the same beat the loop was exiting finds
// `drainTask` still set and skips its kick re-check. No spin: the
// loop only exits non-empty when the kick guard blocks anyway.
if !self.backlog.isEmpty { self.kickDrain() }
}
}
/// Serial delivery of the backlog: one write at a time, oldest due slot first,
/// exponential backoff per entry. Sleeps in short quanta while nothing is due
/// so a fresh enqueue never waits out a long backoff.
private func drainBacklog() async {
while store != nil, !isRestoring, !backlog.isEmpty {
guard let entry = backlog.nextDue(at: Date()) else {
try? await Task.sleep(for: .seconds(1))
continue
}
await attempt(entry)
}
}
/// Try every pending write immediately, ignoring backoff the app is about to
/// background (last chance before suspension), or a test needs determinism.
func flushPendingWrites() async {
guard store != nil, !isRestoring else { return }
for entry in backlog.entries {
await attempt(entry)
}
}
private func attempt(_ entry: PendingWrite) async {
switch await perform(entry) {
case .success:
backlog.resolve(id: entry.documentID, ifTimestampAtMost: entry.timestamp)
case .retry:
backlog.markFailed(id: entry.documentID, at: Date())
case .fault(let message):
backlog.markFailed(id: entry.documentID, at: Date(), fault: message)
}
persistBacklog()
refreshQueueState()
}
private enum WriteOutcome {
case success
case retry
case fault(String)
}
private func perform(_ entry: PendingWrite) async -> WriteOutcome {
guard let store, let tombstones else { return .retry }
do {
switch entry.payload {
case .split(let doc):
try await store.write(doc, to: doc.relativePath)
case .workout(let doc):
try await store.write(doc, to: doc.relativePath)
case .delete(let id, let kind, let livePath):
// The stub is the authoritative delete record (it vetoes
// resurrection everywhere); the live-file removal is best-effort
// the observer's stub handling and reconcile both reap a straggler.
try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: entry.timestamp, kind: kind))
try? await store.remove(at: livePath)
}
for stale in entry.stalePaths where stale != entry.targetPath {
try? await store.remove(at: stale)
}
return .success
} catch {
log.error("write queue: \(entry.documentID, privacy: .public) failed (attempt \(entry.attempts + 1)): \(error)")
if let message = Self.unrecoverableDescription(for: error) { return .fault(message) }
return .retry
}
}
/// Errors no amount of retrying fixes escalate the banner immediately.
/// (The drain still retries them on the long cadence; conditions can change.)
private static func unrecoverableDescription(for error: Error) -> String? {
let ns = error as NSError
guard ns.domain == NSCocoaErrorDomain else { return nil }
switch ns.code {
case NSFileWriteOutOfSpaceError:
return "iPhone storage is full — changes can't be saved to iCloud."
case NSFileWriteVolumeReadOnlyError:
return "iCloud storage is read-only — changes can't be saved."
default:
return nil
}
}
private func persistBacklog() {
WriteBacklogFile.save(backlog, to: backlogURL)
}
private func refreshQueueState() {
let state: WriteQueueState
if backlog.isEmpty {
state = .idle
} else if let fault = backlog.firstFaultMessage {
state = .fault(fault)
} else if backlog.maxAttempts >= WriteBacklog.tier2AttemptThreshold {
state = .fault("Changes aren't reaching iCloud.")
} else if backlog.maxAttempts >= WriteBacklog.tier1AttemptThreshold {
state = .retrying
} else {
state = .pending
}
if state != writeQueueState { writeQueueState = state }
}
/// True when a queued-but-unwritten local change for `id` is at least as new
/// as the file content just read the cache already reflects the newer
/// pending version, so importing the older file would regress it until the
/// drain rewrites. (A pending `.delete` compares via its deletion stamp and
/// keeps the import from resurrecting the entity.)
private func supersededByPendingWrite(id: String, fileTimestamp: Date) -> Bool {
guard let pending = backlog.pendingWrite(for: id) else { return false }
return pending.timestamp >= fileTimestamp
}
// MARK: - Import / reconcile
private func importFile(relativePath: String) async {
@@ -453,10 +646,12 @@ final class SyncEngine {
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
}
}
@@ -496,26 +691,34 @@ final class SyncEngine {
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
}
}
// Prune cache entities no longer backed by a live file but never for
// a path that failed to read this pass (it may just be un-downloadable
// right now; pruning would make an eviction look like a deletion).
// right now; pruning would make an eviction look like a deletion), and
// never for an id with a queued-but-unwritten write (its file simply
// hasn't landed yet; pruning would evaporate the pending edit's mirror).
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) {
for s in splits where !liveSplitIDs.contains(s.id)
&& !unreadablePaths.contains(s.jsonRelativePath)
&& backlog.pendingWrite(for: s.id) == nil {
context.delete(s)
}
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) {
for w in workouts where !liveWorkoutIDs.contains(w.id)
&& !unreadablePaths.contains(w.jsonRelativePath)
&& backlog.pendingWrite(for: w.id) == nil {
context.delete(w)
}
}
@@ -537,12 +740,16 @@ final class SyncEngine {
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
}
/// Observer `.removed` handling. Skips entities with a queued-but-unwritten
/// write: their file may simply not have landed yet (or an old-path removal
/// raced the rewrite), and the pending edit's mirror must survive until the
/// drain delivers it.
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.forEach(context.delete)
splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.forEach(context.delete)
workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
}
+203
View File
@@ -0,0 +1,203 @@
//
// WriteBacklog.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// One queued write: the latest pending version of a single document (or its
/// deletion), plus retry bookkeeping. `timestamp` is the document's `updatedAt`
/// (a delete's deletion stamp) slot replacement and the import/reconcile
/// supersede checks key on it, newer wins.
struct PendingWrite: Codable, Sendable {
enum Payload: Codable, Sendable {
case split(SplitDocument)
case workout(WorkoutDocument)
case delete(id: String, kind: String, livePath: String)
}
var payload: Payload
var timestamp: Date
/// Paths this document previously lived at (month re-bucketing, replaced
/// pending versions, a delete that superseded a queued save) best-effort
/// removed after the write lands so the id never survives at two paths.
var stalePaths: Set<String> = []
/// When the slot was first occupied replacement keeps it, so the TTL
/// measures how long the user's intent has been stuck, not the last edit.
var enqueuedAt: Date
var attempts: Int = 0
var lastAttemptAt: Date?
/// Set when an attempt failed with an error retrying can't fix (disk full).
/// The drain keeps retrying regardless conditions can change but the
/// banner escalates immediately.
var faultMessage: String?
var documentID: String {
switch payload {
case .split(let doc): doc.id
case .workout(let doc): doc.id
case .delete(let id, _, _): id
}
}
/// Where a successful write lands; nil for deletes (they only remove).
var targetPath: String? {
switch payload {
case .split(let doc): doc.relativePath
case .workout(let doc): doc.relativePath
case .delete: nil
}
}
var nextAttemptAt: Date {
guard let lastAttemptAt else { return enqueuedAt }
return lastAttemptAt.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: attempts))
}
}
/// Depth-1 per-document write queue: at most one pending write per document id,
/// newest `updatedAt` wins the slot. Pure state + policy no I/O, no clock of
/// its own so the whole replacement/retry/TTL table is unit-testable.
/// `SyncEngine` owns the drain loop and persistence.
struct WriteBacklog: Codable, Sendable {
private(set) var slots: [String: PendingWrite] = [:]
var isEmpty: Bool { slots.isEmpty }
var count: Int { slots.count }
/// Highest attempt count across pending writes drives the banner tiers.
var maxAttempts: Int { slots.values.map(\.attempts).max() ?? 0 }
/// The oldest entry's fault, if any entry has hit an unrecoverable error.
var firstFaultMessage: String? {
entries.compactMap(\.faultMessage).first
}
/// All pending writes, oldest slot first.
var entries: [PendingWrite] {
slots.values.sorted { $0.enqueuedAt < $1.enqueuedAt }
}
func pendingWrite(for id: String) -> PendingWrite? { slots[id] }
/// Newer-wins slot replacement. An incoming write at least as new as the
/// occupant takes the slot (equal = the latest local re-save of the same
/// state; content-identical, so last-in is right). It inherits the
/// occupant's retry bookkeeping the backlog is failing for environmental
/// reasons, and resetting attempts on every edit would flap the banner
/// plus its stale paths and its target path when the two differ (the
/// occupant was never written, but an earlier attempt may have landed).
/// An incoming write strictly older than the occupant is dropped.
mutating func enqueue(_ write: PendingWrite) {
let id = write.documentID
guard let existing = slots[id] else {
slots[id] = write
return
}
guard write.timestamp >= existing.timestamp else { return }
var merged = write
merged.stalePaths.formUnion(existing.stalePaths)
if let oldPath = existing.targetPath, oldPath != merged.targetPath {
merged.stalePaths.insert(oldPath)
}
merged.enqueuedAt = existing.enqueuedAt
merged.attempts = existing.attempts
merged.lastAttemptAt = existing.lastAttemptAt
merged.faultMessage = existing.faultMessage
slots[id] = merged
}
mutating func markFailed(id: String, at now: Date, fault: String? = nil) {
guard var entry = slots[id] else { return }
entry.attempts += 1
entry.lastAttemptAt = now
if let fault { entry.faultMessage = fault }
slots[id] = entry
}
/// Remove after a successful write replacement-safe: a newer write that
/// slotted in while this one was in flight stays queued.
mutating func resolve(id: String, ifTimestampAtMost timestamp: Date) {
guard let entry = slots[id], entry.timestamp <= timestamp else { return }
slots[id] = nil
}
mutating func removeAll() { slots.removeAll() }
/// Drop entries stuck longer than `ttl` applied when a persisted backlog
/// is loaded, so a slot from a long-dead run can't drain stale state into a
/// container that has long since moved on.
mutating func pruneExpired(now: Date, ttl: TimeInterval = 24 * 60 * 60) {
slots = slots.filter { now.timeIntervalSince($0.value.enqueuedAt) < ttl }
}
/// The next entry whose backoff has elapsed, oldest slot first.
func nextDue(at now: Date) -> PendingWrite? {
entries.first { $0.nextAttemptAt <= now }
}
func earliestNextAttempt() -> Date? {
slots.values.map(\.nextAttemptAt).min()
}
/// Backoff schedule: three quick silent retries (tier 0, ~14 s total), then
/// calm long retries capped at 5 minutes.
static func retryDelay(afterAttempts attempts: Int) -> TimeInterval {
switch attempts {
case ..<1: 0
case 1: 2
case 2: 4
case 3: 8
case 4: 30
case 5: 60
case 6: 120
default: 300
}
}
/// Attempts after which the calm "changes pending" banner shows (~14 s of
/// silent retries have failed).
static let tier1AttemptThreshold = 3
/// Attempts after which the backlog is treated as a fault even without an
/// unrecoverable error (~10 minutes of failures).
static let tier2AttemptThreshold = 8
}
/// UI-facing rollup of the backlog the banner tiers.
enum WriteQueueState: Equatable {
/// Nothing queued.
case idle
/// Writes queued inside the silent tier-0 window no UI.
case pending
/// Tier 1: writes keep failing; calm "changes pending" banner.
case retrying
/// Tier 2: unrecoverable error or backoff exhaustion; prominent banner
/// pointing at Diagnostics.
case fault(String)
}
/// Load/persist the backlog sidecar file. Failures are non-fatal by design: an
/// unreadable file loads as an empty backlog (worst case, queued-but-unwritten
/// edits from a previous run are lost the same loss the queue exists to
/// narrow, never corruption), and a failed persist leaves the previous file in
/// place for the in-memory backlog to overwrite on the next change.
enum WriteBacklogFile {
static func load(from url: URL) -> WriteBacklog {
guard let data = try? Data(contentsOf: url),
let backlog = try? JSONDecoder().decode(WriteBacklog.self, from: data) else {
return WriteBacklog()
}
return backlog
}
static func save(_ backlog: WriteBacklog, to url: URL) {
if backlog.isEmpty {
try? FileManager.default.removeItem(at: url)
return
}
guard let data = try? JSONEncoder().encode(backlog) else { return }
try? data.write(to: url, options: .atomic)
}
}
@@ -0,0 +1,66 @@
//
// SyncStatusBanner.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// Surfaces the write queue's banner tiers at the bottom of the main screen.
/// Tier 0 (fresh writes, silent retries) shows nothing; tier 1 is a calm
/// "changes pending" capsule that clears itself on success; tier 2 is a
/// prominent fault that opens Diagnostics.
struct SyncStatusBanner: View {
@Environment(SyncEngine.self) private var sync
@State private var showingDiagnostics = false
var body: some View {
Group {
switch sync.writeQueueState {
case .idle, .pending:
EmptyView()
case .retrying:
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Changes pending — retrying…")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.regularMaterial, in: Capsule())
.padding(.bottom, 6)
case .fault(let message):
Button {
showingDiagnostics = true
} label: {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: "exclamationmark.icloud.fill")
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 2) {
Text(message)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
Text("Your changes are safe on this iPhone. Tap for diagnostics.")
.font(.caption2)
.foregroundStyle(.white.opacity(0.85))
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.red.gradient, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.bottom, 6)
.sheet(isPresented: $showingDiagnostics) {
NavigationStack { DiagnosticsView() }
}
}
}
.animation(.default, value: sync.writeQueueState)
}
}
-61
View File
@@ -1,61 +0,0 @@
//
// SplitItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 2:45 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitItem: View {
var split: Split
var body: some View {
VStack {
ZStack(alignment: .bottom) {
// Golden ratio rectangle (1:1.618)
RoundedRectangle(cornerRadius: 12)
.fill(
LinearGradient(
gradient: Gradient(colors: [splitColor, splitColor.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(1.618, contentMode: .fit)
.shadow(radius: 2)
GeometryReader { geo in
VStack(spacing: 4) {
Spacer()
// Icon in the center - using dynamic sizing
Image(systemName: split.systemImage)
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
.scaledToFit()
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
.padding(.bottom, 4)
// Name at the bottom inside the rectangle
Text(split.name)
.font(.headline)
.lineLimit(1)
.padding(.horizontal, 8)
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.padding(.bottom, 8)
}
.foregroundColor(.white)
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}
private var splitColor: Color {
Color.color(from: split.color)
}
}
+49 -20
View File
@@ -10,39 +10,37 @@
import SwiftUI
import SwiftData
/// The Splits library screen, pushed from Settings Library: a two-column grid of
/// split tiles. Tapping a tile opens `SplitDetailView`; long-pressing offers Delete
/// (the sole split-delete affordance); the toolbar's + adds a new split. Starter
/// splits are seeded automatically on the true first run (`SyncEngine.autoSeedIfEmpty`),
/// so an empty library just invites creating one.
/// The Splits library screen, pushed from Settings Library: a plain list of
/// splits, one row each. Tapping a row opens `SplitDetailView`; long-pressing
/// offers Delete (the sole split-delete affordance); the toolbar's + adds a new
/// split. Starter splits are seeded automatically on the true first run
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
struct SplitListView: View {
@Environment(SyncEngine.self) private var sync
@Query(sort: \Split.order) private var splits: [Split]
@Query(sort: \Split.name) private var splits: [Split]
@State private var showingAddSheet = false
@State private var splitToDelete: Split?
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
List {
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitRow(split: split)
}
.contextMenu {
Button(role: .destructive) {
splitToDelete = split
} label: {
SplitItem(split: split)
}
.contextMenu {
Button(role: .destructive) {
splitToDelete = split
} label: {
Label("Delete", systemImage: "trash")
}
Label("Delete", systemImage: "trash")
}
}
}
.padding()
}
.listStyle(.insetGrouped)
.overlay {
if splits.isEmpty {
ContentUnavailableView(
@@ -87,3 +85,34 @@ struct SplitListView: View {
}
}
}
/// A single split row: a small rounded-square badge (the split's color at low
/// opacity, its symbol tinted full-strength) beside the split name and its
/// exercise count.
private struct SplitRow: View {
var split: Split
var body: some View {
HStack(spacing: 12) {
Image(systemName: split.systemImage)
.font(.title3)
.foregroundStyle(splitColor)
.frame(width: 36, height: 36)
.background(splitColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(split.name)
.foregroundStyle(.primary)
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var splitColor: Color {
Color.color(from: split.color)
}
}
@@ -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 {
+5
View File
@@ -31,6 +31,11 @@ struct WorkoutsApp: App {
// the app is backgrounded, so re-assert it each time the scene becomes active.
.onChange(of: scenePhase, initial: true) { _, phase in
UIApplication.shared.isIdleTimerDisabled = (phase == .active)
// Last chance before suspension: push any queued document writes out
// now rather than waiting out a retry backoff we may not live to see.
if phase == .background {
Task { await services.syncEngine.flushPendingWrites() }
}
}
}