From c05e83cff73ac35c14eca569d5a5296ecbc17032 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 8 Jul 2026 12:48:12 -0400 Subject: [PATCH] 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 --- .../Persistence/WorkoutsModelContainer.swift | 9 + Shared/Utils/Date+Extensions.swift | 15 + TODO.md | 15 + .../Views/WorkoutLogListView.swift | 19 + Workouts/Sync/SyncEngine.swift | 305 +++++++++-- Workouts/Sync/WriteBacklog.swift | 203 ++++++++ Workouts/Views/Common/SyncStatusBanner.swift | 66 +++ Workouts/Views/Splits/SplitItem.swift | 61 --- Workouts/Views/Splits/SplitListView.swift | 69 ++- .../WorkoutLogs/WorkoutLogListView.swift | 3 +- .../Views/WorkoutLogs/WorkoutLogsView.swift | 75 ++- Workouts/WorkoutsApp.swift | 5 + WorkoutsTests/WriteBacklogTests.swift | 474 ++++++++++++++++++ 13 files changed, 1175 insertions(+), 144 deletions(-) create mode 100644 Workouts/Sync/WriteBacklog.swift create mode 100644 Workouts/Views/Common/SyncStatusBanner.swift delete mode 100644 Workouts/Views/Splits/SplitItem.swift create mode 100644 WorkoutsTests/WriteBacklogTests.swift diff --git a/Shared/Persistence/WorkoutsModelContainer.swift b/Shared/Persistence/WorkoutsModelContainer.swift index 80d713b..bbfcb59 100644 --- a/Shared/Persistence/WorkoutsModelContainer.swift +++ b/Shared/Persistence/WorkoutsModelContainer.swift @@ -18,6 +18,14 @@ enum WorkoutsModelContainer { URL.applicationSupportDirectory.appending(path: "Workouts.store") } + /// Sidecar file holding queued-but-unwritten document writes (`WriteBacklog`, + /// iOS only). Lives beside the cache store — outside the iCloud container — + /// and is wiped with it on an account change: a backlog from one account must + /// never drain into another account's container. + static var pendingWritesURL: URL { + URL.applicationSupportDirectory.appending(path: "PendingWrites.json") + } + static func make() -> ModelContainer { let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self]) ensureStoreDirectoryExists() @@ -70,6 +78,7 @@ enum WorkoutsModelContainer { let stored = UserDefaults.standard.data(forKey: identityTokenKey) guard current != stored else { return } wipeStore() + try? FileManager.default.removeItem(at: pendingWritesURL) UserDefaults.standard.set(current, forKey: identityTokenKey) } diff --git a/Shared/Utils/Date+Extensions.swift b/Shared/Utils/Date+Extensions.swift index e4d0728..3ae4ea0 100644 --- a/Shared/Utils/Date+Extensions.swift +++ b/Shared/Utils/Date+Extensions.swift @@ -39,6 +39,21 @@ extension Date { var abbreviatedWeekday: String { Self.weekdayAbbrev.string(from: self) } var dayOfMonth: Int { Calendar.current.component(.day, from: self) } + /// "Today", "Yesterday", or "N days ago" by calendar-day difference. + func daysAgoLabel(relativeTo now: Date = Date()) -> String { + let calendar = Calendar.current + let days = calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: self), + to: calendar.startOfDay(for: now) + ).day ?? 0 + switch days { + case ..<1: return "Today" + case 1: return "Yesterday" + default: return "\(days) days ago" + } + } + func humanTimeInterval(to other: Date) -> String { let interval = other.timeIntervalSince(self) let hours = Int(interval) / 3600 diff --git a/TODO.md b/TODO.md index 8bdece1..07fa412 100644 --- a/TODO.md +++ b/TODO.md @@ -21,3 +21,18 @@ list (`WorkoutLogListView`) has UI already waiting on these: machine settings, and its own "add exercise to a running workout" path (`Workouts Watch App/Views/WorkoutLogListView.swift`) doesn't snapshot `machineSettings` into the new log. + +## Wish list + +- **Pinned splits in the split picker**: add a "Pinned" section above "Recent" in + `SplitPickerSheet` (`Workouts/Views/WorkoutLogs/WorkoutLogsView.swift`), with a + swipe-to-pin action on the rows (and possibly on `SplitListView` for symmetry). + Storage decision (2026-07-08): a `pinnedSplitIDs` set in `UserDefaults` — matches + the existing preference precedent (`restSeconds`, `weightUnit`) and survives cache + rebuilds. Do **not** put an `isPinned` flag in `SplitDocument`: pinning a starter + seed would trigger the clone-on-edit fork, and `reconcileSeeds()` would overwrite + the flag with bundle bytes anyway. At read time, resolve stored IDs through + `sync.currentSplitID(for:)` (as `recentSplits` does) and prune IDs whose split no + longer exists; exclude pinned splits from the Recent section. Upgrade path if + cross-device pins ever matter: a tiny `Preferences/pins.json` iCloud document. + Watch, if wanted later: push the ID list via the `WCPayload` application context. diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index a97cc6e..7266963 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -19,10 +19,15 @@ struct WorkoutLogListView: View { /// race against the cache, which lags local writes by a beat. @State private var doc: WorkoutDocument + /// The live cache entity, kept only to observe `updatedAt` for the absorb + /// below — all rendering and editing goes through the `doc` snapshot. + private let workout: Workout + @State private var showingExercisePicker = false @State private var selectedLogID: String? init(workout: Workout) { + self.workout = workout _doc = State(initialValue: WorkoutDocument(from: workout)) } @@ -94,6 +99,20 @@ struct WorkoutLogListView: View { } } .navigationTitle(doc.splitName ?? Split.unnamed) + // Absorb phone edits while this screen is open (H1's watch-side gap): + // without this, the snapshot stays frozen at init, and the next watch + // tick — made against a state that's missing the phone's edit — would be + // stamped fresh and clobber it wholesale on the phone. Strictly-newer + // guard only: an equal timestamp is our own edit echoing back through + // the phone's push, and a stale push in flight (the phone pushed before + // ingesting our latest edit) must not regress what the user is looking + // at. Clock skew across devices degrades this to a skipped absorb — + // never to a clobber of local state. + .onChange(of: workout.updatedAt) { _, incoming in + if incoming > doc.updatedAt { + doc = WorkoutDocument(from: workout) + } + } .navigationDestination(item: $selectedLogID) { logID in ExerciseProgressView( doc: $doc, diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index de0e46e..d2d3e5b 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -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? private var connectAttempt = 0 + private var backlog: WriteBacklog + private let backlogURL: URL + private var drainTask: Task? /// 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()) { - 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()) { - 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(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(predicate: #Predicate { $0.jsonRelativePath == path })) { - workouts.forEach(context.delete) + workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete) } } diff --git a/Workouts/Sync/WriteBacklog.swift b/Workouts/Sync/WriteBacklog.swift new file mode 100644 index 0000000..8602319 --- /dev/null +++ b/Workouts/Sync/WriteBacklog.swift @@ -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 = [] + /// 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) + } +} diff --git a/Workouts/Views/Common/SyncStatusBanner.swift b/Workouts/Views/Common/SyncStatusBanner.swift new file mode 100644 index 0000000..830cc88 --- /dev/null +++ b/Workouts/Views/Common/SyncStatusBanner.swift @@ -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) + } +} diff --git a/Workouts/Views/Splits/SplitItem.swift b/Workouts/Views/Splits/SplitItem.swift deleted file mode 100644 index cef6ce9..0000000 --- a/Workouts/Views/Splits/SplitItem.swift +++ /dev/null @@ -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) - } -} diff --git a/Workouts/Views/Splits/SplitListView.swift b/Workouts/Views/Splits/SplitListView.swift index 31c6fa2..1b2f1a4 100644 --- a/Workouts/Views/Splits/SplitListView.swift +++ b/Workouts/Views/Splits/SplitListView.swift @@ -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) + } +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index d6318fa..490b532 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -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 } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 5a19951..682aac1 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -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() + 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 { diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index ca066ea..94a4a3c 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -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() } + } } } diff --git a/WorkoutsTests/WriteBacklogTests.swift b/WorkoutsTests/WriteBacklogTests.swift new file mode 100644 index 0000000..c9f181e --- /dev/null +++ b/WorkoutsTests/WriteBacklogTests.swift @@ -0,0 +1,474 @@ +import Foundation +import SwiftData +import Testing +@testable import Workouts + +/// Pins the pure decision table of `WriteBacklog` — the depth-1 per-document write +/// queue `SyncEngine` drains to disk. Every operation here is a `struct`-level +/// mutation with no I/O and no clock of its own, so replacement, retry bookkeeping, +/// backoff, and TTL pruning are all deterministic and directly testable. +/// `SyncEngineWriteQueueTests` below covers how the engine wires this into the cache +/// and the `save`/`delete`/`ingestFromWatch` call sites. +struct WriteBacklogTests { + + private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + /// Mid-month, noon dates in the current time zone — matches + /// `WorkoutPathBucketingTests`'s pattern so a derived `relativePath` month bucket + /// can never be nudged across a boundary by a real-world UTC offset. + private func date(year: Int, month: Int, day: Int = 15, hour: Int = 12) -> Date { + var comps = DateComponents() + comps.year = year; comps.month = month; comps.day = day; comps.hour = hour + return Calendar(identifier: .gregorian).date(from: comps)! + } + + private func workoutDoc(id: String, start: Date, updatedAt: Date, splitName: String? = nil) -> WorkoutDocument { + WorkoutDocument( + schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: nil, + splitName: splitName, start: start, end: nil, + status: WorkoutStatus.notStarted.rawValue, createdAt: updatedAt, updatedAt: updatedAt, + logs: [], metrics: nil + ) + } + + /// Builds a `PendingWrite` with a workout payload and full control over every + /// bookkeeping field, so replacement/retry/backoff tests can set up exact + /// preconditions directly instead of driving them indirectly through `SyncEngine`. + private func pendingWorkout( + id: String, start: Date, timestamp: Date, enqueuedAt: Date, splitName: String? = nil, + attempts: Int = 0, lastAttemptAt: Date? = nil, faultMessage: String? = nil, + stalePaths: Set = [] + ) -> PendingWrite { + PendingWrite( + payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, splitName: splitName)), + timestamp: timestamp, stalePaths: stalePaths, enqueuedAt: enqueuedAt, + attempts: attempts, lastAttemptAt: lastAttemptAt, faultMessage: faultMessage + ) + } + + // MARK: - enqueue + + /// Enqueuing into an empty slot stores the write verbatim, and `count`/`isEmpty` + /// reflect the newly-occupied slot — the base case every replacement test below + /// builds on. + @Test func enqueueIntoEmptySlotStoresWrite() { + var backlog = WriteBacklog() + #expect(backlog.isEmpty) + #expect(backlog.count == 0) + + let write = pendingWorkout(id: "01ENQUEUEEMPTY0000000001", start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0) + backlog.enqueue(write) + + #expect(!backlog.isEmpty) + #expect(backlog.count == 1) + #expect(backlog.pendingWrite(for: write.documentID)?.timestamp == Self.t0) + } + + /// Newer-wins replacement: the incoming document's content takes the slot, but it + /// inherits the FIRST entry's retry bookkeeping (`enqueuedAt`, `attempts`, + /// `lastAttemptAt`, `faultMessage`) — resetting those on every edit would flap the + /// retry banner even though the backlog is failing for environmental reasons, not + /// because of what's in the slot. Stale paths union both sides' preexisting sets, + /// plus the occupant's now-abandoned target path (a month-rebucketing edit moves a + /// workout's file; an earlier attempt may already have landed at the old path). + @Test func newerWinsReplacementInheritsBookkeepingAndUnionsStalePaths() throws { + var backlog = WriteBacklog() + let id = "01NEWERWINS0000000000001" + let marchStart = date(year: 2024, month: 3) + let aprilStart = date(year: 2024, month: 4) + let oldTargetPath = "Workouts/2024/03/\(id).json" + + let existing = pendingWorkout( + id: id, start: marchStart, timestamp: Self.t0, + enqueuedAt: Self.t0.addingTimeInterval(-100), + attempts: 2, lastAttemptAt: Self.t0.addingTimeInterval(-10), faultMessage: "disk full", + stalePaths: ["Workouts/2024/02/\(id).json"] + ) + #expect(existing.targetPath == oldTargetPath) + backlog.enqueue(existing) + + let incoming = pendingWorkout( + id: id, start: aprilStart, timestamp: Self.t0.addingTimeInterval(10), + enqueuedAt: Self.t0, // would be its own enqueuedAt if it were the first — must be overridden + stalePaths: ["Workouts/2024/05/\(id).json"] + ) + backlog.enqueue(incoming) + + let slot = try #require(backlog.pendingWrite(for: id)) + #expect(slot.timestamp == incoming.timestamp) + guard case .workout(let doc) = slot.payload else { + Issue.record("expected a workout payload"); return + } + #expect(doc.start == aprilStart) // the newer document content wins the slot + + #expect(slot.enqueuedAt == existing.enqueuedAt) + #expect(slot.attempts == existing.attempts) + #expect(slot.lastAttemptAt == existing.lastAttemptAt) + #expect(slot.faultMessage == existing.faultMessage) + + #expect(slot.stalePaths.isSuperset(of: existing.stalePaths)) + #expect(slot.stalePaths.isSuperset(of: incoming.stalePaths)) + #expect(slot.stalePaths.contains(oldTargetPath)) + } + + /// An incoming write at exactly the occupant's timestamp still wins the slot — + /// equal timestamps are treated as the latest local re-save of the same logical + /// state, so last-in is correct. + @Test func equalTimestampIncomingWinsSlot() throws { + var backlog = WriteBacklog() + let id = "01EQUALTIMESTAMP000000001" + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Original")) + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Incoming")) + + let slot = try #require(backlog.pendingWrite(for: id)) + guard case .workout(let doc) = slot.payload else { + Issue.record("expected a workout payload"); return + } + #expect(doc.splitName == "Incoming") + } + + /// An incoming write strictly older than the occupant is dropped outright — the + /// slot (and its bookkeeping) is left completely unchanged. + @Test func strictlyOlderIncomingIsDropped() throws { + var backlog = WriteBacklog() + let id = "01OLDERDROPPED0000000001" + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Kept")) + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, splitName: "Ignored")) + + let slot = try #require(backlog.pendingWrite(for: id)) + guard case .workout(let doc) = slot.payload else { + Issue.record("expected a workout payload"); return + } + #expect(doc.splitName == "Kept") + #expect(slot.timestamp == Self.t0) + } + + // MARK: - resolve + + /// `resolve(id:ifTimestampAtMost:)` is the replacement-safety property the drain + /// relies on: it removes the slot when the just-written version is still current + /// (`entry.timestamp <= given`), but a write that slotted in WHILE the attempt was + /// in flight (`entry.timestamp > given`) survives — the drain only just wrote the + /// OLD content, so the newer queued edit must not be discarded as if it had landed. + @Test func resolveRemovesOnlyWhenNoNewerWriteSlottedIn() throws { + var backlog = WriteBacklog() + let id = "01RESOLVESAFETY0000000001" + let firstAttemptTimestamp = Self.t0 + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: firstAttemptTimestamp, enqueuedAt: Self.t0)) + + let newerTimestamp = Self.t0.addingTimeInterval(30) + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: newerTimestamp, enqueuedAt: Self.t0)) + + backlog.resolve(id: id, ifTimestampAtMost: firstAttemptTimestamp) + let survivor = try #require(backlog.pendingWrite(for: id)) + #expect(survivor.timestamp == newerTimestamp) + + backlog.resolve(id: id, ifTimestampAtMost: newerTimestamp) + #expect(backlog.pendingWrite(for: id) == nil) + } + + // MARK: - markFailed + + /// `markFailed` increments attempts and stamps `lastAttemptAt` on every call, only + /// overwrites `faultMessage` when a new one is supplied (an unrecoverable error + /// shouldn't be forgotten by a subsequent plain retry failure), `maxAttempts` is + /// the max across every slot, and `firstFaultMessage` walks slots oldest-`enqueuedAt` + /// first — so once the OLDEST slot also faults, its message takes priority even + /// though a newer slot faulted first in wall-clock time. + @Test func markFailedTracksAttemptsFaultPriorityAndMax() throws { + var backlog = WriteBacklog() + let older = "01MARKFAILEDOLDER00000001" // enqueued first + let newer = "01MARKFAILEDNEWER00000001" // enqueued second + backlog.enqueue(pendingWorkout(id: older, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)) + backlog.enqueue(pendingWorkout(id: newer, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(5))) + #expect(backlog.maxAttempts == 0) + #expect(backlog.firstFaultMessage == nil) + + let failAt1 = Self.t0.addingTimeInterval(1) + backlog.markFailed(id: older, at: failAt1) + var entry = try #require(backlog.pendingWrite(for: older)) + #expect(entry.attempts == 1) + #expect(entry.lastAttemptAt == failAt1) + #expect(entry.faultMessage == nil) + + // `newer` is the only faulting slot so far — it surfaces even though `older` + // was enqueued earlier and hasn't faulted. + backlog.markFailed(id: newer, at: Self.t0.addingTimeInterval(2), fault: "disk full") + #expect(backlog.firstFaultMessage == "disk full") + + // Once `older` (the earlier-enqueued slot) also faults, its message takes + // priority — `firstFaultMessage` walks entries oldest-first. + backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(3), fault: "older fault") + #expect(backlog.firstFaultMessage == "older fault") + + // A later failure with no new fault keeps the existing message rather than + // clearing it. + backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(4)) + entry = try #require(backlog.pendingWrite(for: older)) + #expect(entry.attempts == 3) + #expect(entry.faultMessage == "older fault") + + #expect(backlog.maxAttempts == 3) // older: 3 attempts, newer: 1 + } + + // MARK: - Backoff scheduling + + /// `nextDue`/`earliestNextAttempt` are the drain loop's scheduling surface: a + /// fresh entry (never attempted) is due immediately at its `enqueuedAt`, and each + /// failure re-anchors the backoff at `retryDelay(afterAttempts:)` from the FAILURE + /// time — not cumulative, not from `enqueuedAt`. + @Test func nextDueAndEarliestNextAttemptHonorBackoff() throws { + var backlog = WriteBacklog() + let id = "01BACKOFFDUE0000000000001" + let enqueuedAt = Self.t0 + backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: enqueuedAt)) + // A second, never-failing slot enqueued much later — present throughout so + // `earliestNextAttempt` is genuinely picking a minimum across slots, not just + // echoing the only one that exists. + let freshID = "01BACKOFFFRESH00000000001" + backlog.enqueue(pendingWorkout(id: freshID, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(10_000))) + + var entry = try #require(backlog.pendingWrite(for: id)) + #expect(entry.nextAttemptAt == enqueuedAt) + #expect(backlog.nextDue(at: enqueuedAt.addingTimeInterval(-1)) == nil) + #expect(backlog.nextDue(at: enqueuedAt)?.documentID == id) + #expect(backlog.earliestNextAttempt() == enqueuedAt) + + let firstFailure = Self.t0.addingTimeInterval(100) + backlog.markFailed(id: id, at: firstFailure) + entry = try #require(backlog.pendingWrite(for: id)) + let expectedFirstRetry = firstFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 1)) + #expect(entry.nextAttemptAt == expectedFirstRetry) + #expect(backlog.nextDue(at: expectedFirstRetry.addingTimeInterval(-0.5)) == nil) + #expect(backlog.nextDue(at: expectedFirstRetry)?.documentID == id) + + let secondFailure = expectedFirstRetry.addingTimeInterval(50) + backlog.markFailed(id: id, at: secondFailure) + entry = try #require(backlog.pendingWrite(for: id)) + let expectedSecondRetry = secondFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 2)) + #expect(entry.nextAttemptAt == expectedSecondRetry) + #expect(backlog.earliestNextAttempt() == expectedSecondRetry) + } + + /// Locks the exact backoff table: three quick silent retries (2s/4s/8s), then calm + /// long retries, capped at 5 minutes from attempt 7 onward. + @Test func retryDelayScheduleMatchesBackoffTable() { + #expect(WriteBacklog.retryDelay(afterAttempts: 0) == 0) + #expect(WriteBacklog.retryDelay(afterAttempts: 1) == 2) + #expect(WriteBacklog.retryDelay(afterAttempts: 2) == 4) + #expect(WriteBacklog.retryDelay(afterAttempts: 3) == 8) + #expect(WriteBacklog.retryDelay(afterAttempts: 4) == 30) + #expect(WriteBacklog.retryDelay(afterAttempts: 5) == 60) + #expect(WriteBacklog.retryDelay(afterAttempts: 6) == 120) + #expect(WriteBacklog.retryDelay(afterAttempts: 7) == 300) + #expect(WriteBacklog.retryDelay(afterAttempts: 50) == 300) // capped, not just "7" + } + + // MARK: - pruneExpired + + /// A persisted backlog loaded on relaunch drops any slot stuck longer than the + /// TTL — a run that died with a queued write shouldn't drain stale state into a + /// container that's long since moved on — while a younger slot survives untouched. + @Test func pruneExpiredDropsOnlyEntriesPastTheTTL() throws { + var backlog = WriteBacklog() + let now = Self.t0 + let ttl: TimeInterval = 60 + let youngID = "01PRUNEYOUNG0000000000001" + let oldID = "01PRUNEOLD0000000000000001" + backlog.enqueue(pendingWorkout(id: youngID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-30))) + backlog.enqueue(pendingWorkout(id: oldID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-90))) + + backlog.pruneExpired(now: now, ttl: ttl) + + #expect(backlog.pendingWrite(for: youngID) != nil) + #expect(backlog.pendingWrite(for: oldID) == nil) + #expect(backlog.count == 1) + } + + // MARK: - WriteBacklogFile + + /// The sidecar file round-trips a non-empty backlog's slots (ids and timestamps + /// intact), and saving an EMPTY backlog removes any existing file instead of + /// writing `{}` — the sidecar's absence IS "no pending writes," matching what a + /// fresh install/relaunch with nothing queued looks like on disk. + @Test func fileRoundTripPreservesSlotsAndEmptySaveRemovesFile() throws { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".json") + defer { try? FileManager.default.removeItem(at: url) } + + var backlog = WriteBacklog() + let idA = "01FILEROUNDTRIPA00000001" + let idB = "01FILEROUNDTRIPB00000001" + backlog.enqueue(pendingWorkout(id: idA, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)) + backlog.enqueue(pendingWorkout(id: idB, start: Self.t0, timestamp: Self.t0.addingTimeInterval(5), enqueuedAt: Self.t0.addingTimeInterval(5))) + + WriteBacklogFile.save(backlog, to: url) + #expect(FileManager.default.fileExists(atPath: url.path)) + + let loaded = WriteBacklogFile.load(from: url) + #expect(loaded.count == 2) + #expect(loaded.pendingWrite(for: idA)?.timestamp == Self.t0) + #expect(loaded.pendingWrite(for: idA)?.documentID == idA) + #expect(loaded.pendingWrite(for: idB)?.timestamp == Self.t0.addingTimeInterval(5)) + + // Saving an EMPTY backlog over an existing file removes it rather than + // writing `{}`. + WriteBacklogFile.save(WriteBacklog(), to: url) + #expect(!FileManager.default.fileExists(atPath: url.path)) + } +} + +// MARK: - SyncEngine integration + +/// Coverage for `SyncEngine`'s write-queue integration: every `save`/`delete` mirrors +/// into the SwiftData cache immediately and enqueues the file write (see +/// `WriteBacklogTests` above for the queue's own pure mechanics). None of these tests +/// call `connect()` — matching `SyncEngineTests`'s scope note, the engine's `store` +/// stays nil, so `kickDrain()` never launches a background drain that could race these +/// assertions, and no real iCloud container is ever touched. Each test builds its own +/// `SyncEngine` against a fresh in-memory `ModelContainer` and a unique temp +/// `backlogURL`, so tests can't contaminate each other or the developer's real +/// Application Support directory. +struct SyncEngineWriteQueueTests { + + private static let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + private func tempBacklogURL() -> URL { + FileManager.default.temporaryDirectory.appending(path: "WriteBacklogTests-\(UUID().uuidString).json") + } + + @MainActor + private func makeEngine(backlogURL: URL) throws -> (engine: SyncEngine, container: ModelContainer) { + let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: [config]) + return (SyncEngine(container: container, backlogURL: backlogURL), container) + } + + private func makeWorkoutDoc(id: String, updatedAt: Date, splitName: String? = "Push Day") -> WorkoutDocument { + WorkoutDocument( + schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "SPLIT-1", + splitName: splitName, start: Self.t0, end: nil, + status: WorkoutStatus.notStarted.rawValue, createdAt: Self.t0, updatedAt: updatedAt, + logs: [], metrics: nil + ) + } + + // MARK: - save(workout:) + + /// A local save mirrors into the cache immediately (a same-process write doesn't + /// reliably wake the metadata observer, and never does in the simulator) and + /// queues exactly one write. + @MainActor + @Test func saveWorkoutMirrorsToCacheAndQueuesWrite() async throws { + let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) + let id = "01SAVEQUEUE0000000000001" + await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) + + let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + #expect(cached.updatedAt == Self.t0) + #expect(engine.pendingWriteCount == 1) + #expect(engine.writeQueueState == .pending) + } + + /// Depth-1 per-document slot: saving the same id twice keeps exactly one queued + /// write, and the cache — which every save mirrors into unconditionally — shows + /// the LATEST version, matching what the eventually-drained file will contain. + @MainActor + @Test func repeatedSaveSameIDStaysDepthOneWithNewestCached() async throws { + let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) + let id = "01DEPTHONE00000000000001" + await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) + await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60))) + + #expect(engine.pendingWriteCount == 1) + let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + #expect(cached.updatedAt == Self.t0.addingTimeInterval(60)) + } + + // MARK: - ingestFromWatch + + /// The watch bridge's `updatedAt` intake gate: a strictly-older push is dropped + /// (the cache is already ahead of the watch), an equal timestamp is the + /// echo/duplicate case and is ignored wholesale even when some other field + /// differs, and a strictly-newer push is accepted. `sendMessage` plus a queued + /// `transferUserInfo` fallback are unordered, so this gate is what keeps a stale + /// racing push from clobbering a newer local save. + @MainActor + @Test func ingestFromWatchAppliesUpdatedAtGate() async throws { + let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) + let id = "01INGESTGATE000000000001" + await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Push Day")) + + // (a) strictly older — dropped, cache unchanged. + await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), splitName: "Push Day")) + var cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + #expect(cached.updatedAt == Self.t0) + + // (b) same updatedAt but a changed field — the echo case, ignored entirely + // (equal timestamp means duplicate, not a genuine edit). + await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Changed Name")) + cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + #expect(cached.updatedAt == Self.t0) + #expect(cached.splitName == "Push Day") + + // (c) strictly newer — accepted. + await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), splitName: "Changed Name")) + cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + #expect(cached.updatedAt == Self.t0.addingTimeInterval(60)) + #expect(cached.splitName == "Changed Name") + } + + /// A queued-but-unwritten delete vetoes resurrection: a watch push racing the + /// delete — even carrying an `updatedAt` newer than the deleted version — must + /// not bring the entity back, because the delete's stub simply hasn't landed on + /// disk yet (the same veto an already-written tombstone would apply). + @MainActor + @Test func pendingDeleteVetoesWatchIngestResurrection() async throws { + let (engine, container) = try makeEngine(backlogURL: tempBacklogURL()) + let id = "01DELETEVETO0000000000001" + await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0)) + let entity = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext)) + await engine.delete(workout: entity) + + await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(120))) + + #expect(CacheMapper.fetchWorkout(id: id, in: container.mainContext) == nil) + } + + // MARK: - Restore lifecycle + + /// `beginRestore()` clears the backlog outright — queued writes reference + /// pre-restore state, and draining them into the just-restored tree would + /// reintroduce edits the user chose to roll back by restoring a backup. + @MainActor + @Test func beginRestoreClearsBacklogAndResetsQueueState() async throws { + let (engine, _) = try makeEngine(backlogURL: tempBacklogURL()) + await engine.save(workout: makeWorkoutDoc(id: "01RESTORECLEAR00000001", updatedAt: Self.t0)) + #expect(engine.pendingWriteCount == 1) + + engine.beginRestore() + #expect(engine.pendingWriteCount == 0) + #expect(engine.writeQueueState == .idle) + + engine.endRestore() // balance the begin/end pair, matching every production call site + } + + // MARK: - Persistence + + /// The backlog sidecar survives across process launches: a second engine pointed + /// at the SAME `backlogURL` picks up the first engine's still-queued write on + /// `init`, before ever connecting — this is what lets a killed app resume + /// draining on next launch instead of silently losing the edit. + @MainActor + @Test func backlogPersistsAcrossEngineInstancesSharingBacklogURL() async throws { + let url = tempBacklogURL() + defer { try? FileManager.default.removeItem(at: url) } + + let (engineA, _) = try makeEngine(backlogURL: url) + await engineA.save(workout: makeWorkoutDoc(id: "01PERSISTBACKLOG000001", updatedAt: Self.t0)) + #expect(engineA.pendingWriteCount == 1) + + let (engineB, _) = try makeEngine(backlogURL: url) + #expect(engineB.pendingWriteCount == 1) + } +}