listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit ebe07dff1a78256cf65cd8099458bfb6661f8fa4
parent cf479e92e14194a6819bfe0fc3ffa3d0e18fd1c4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 19 Feb 2026 05:08:03 +0900

Add CloudKit monitor

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 18++++++++++++++++++
MListless/Extensions/TaskListView+Logic.swift | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
AListless/Extensions/TaskListView+SyncUI.swift | 40++++++++++++++++++++++++++++++++++++++++
MListless/Models/TaskStore.swift | 71++++++++++++++++++++++++++++++++++++++++++-----------------------------
AListless/Sync/CloudKitErrorClassifier.swift | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Sync/CloudKitSyncMonitor.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless/Sync/PersistenceController.swift | 20+++++++++++++-------
MListlessMac/ListlessMacApp.swift | 5++++-
MListlessMac/Views/TaskListView.swift | 34+++++++++++++++++++++++++++++++++-
MListlessiOS/Extensions/TaskListView+Drag.swift | 6+++++-
MListlessiOS/ListlessiOSApp.swift | 5++++-
MListlessiOS/Views/TaskListView.swift | 34+++++++++++++++++++++++++++++++++-
MTests/Support/TestHelpers.swift | 4++--
MTests/Unit/TaskStoreCompletionTests.swift | 102++++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/Unit/TaskStoreEdgeCaseTests.swift | 76++++++++++++++++++++++++++++++++++++++--------------------------------------
MTests/Unit/TaskStoreOrderingTests.swift | 84++++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/Unit/TaskStoreTests.swift | 72++++++++++++++++++++++++++++++++++++------------------------------------
17 files changed, 593 insertions(+), 234 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -18,12 +18,14 @@ 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; }; 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; }; + 4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; }; 5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */; }; 5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; }; 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; }; 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */; }; 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; + 642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; }; 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; }; 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; }; 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; }; @@ -32,6 +34,7 @@ 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; }; 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; }; 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; }; + 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; }; 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; }; 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; @@ -41,11 +44,14 @@ BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; }; C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; }; C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; }; + CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; }; CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; }; DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; }; DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; }; + E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; }; E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; }; + E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; }; E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; }; E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */; }; ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; }; @@ -85,11 +91,14 @@ 5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; }; 632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; }; + 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifier.swift; sourceTree = "<group>"; }; + 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncMonitor.swift; sourceTree = "<group>"; }; 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; }; 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; }; 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Logic.swift"; sourceTree = "<group>"; }; 74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; 75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; + 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+SyncUI.swift"; sourceTree = "<group>"; }; 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; @@ -232,6 +241,7 @@ isa = PBXGroup; children = ( 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */, + 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */, ); path = Extensions; sourceTree = "<group>"; @@ -280,6 +290,8 @@ isa = PBXGroup; children = ( 944BAE054AAC1B9C4FC954F9 /* .gitkeep */, + 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */, + 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */, C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */, ); path = Sync; @@ -424,6 +436,8 @@ files = ( 99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */, DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */, + E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */, + E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */, 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */, 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */, 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */, @@ -433,6 +447,7 @@ DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */, C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */, E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */, + 642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */, 322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */, FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */, 5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */, @@ -447,6 +462,8 @@ files = ( BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */, 5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */, + CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */, + 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */, E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */, F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */, 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */, @@ -464,6 +481,7 @@ 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */, 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */, 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */, + 4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */, 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */, C169823665158AA347A63990 /* TaskListView.swift in Sources */, 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */, diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -35,6 +35,10 @@ extension TaskListView { return nil } + func presentStoreError(_ error: Error) { + syncMonitor.ingest(error: error) + } + private func isLastActiveTask(_ taskID: UUID) -> Bool { guard let lastTask = activeTasks.last else { return false } return lastTask.id == taskID @@ -45,20 +49,29 @@ extension TaskListView { func createNewTaskAtTop() -> UUID { draggedTaskID = nil visualOrder = nil - let task = store.createTask(title: "", atBeginning: true) - pendingFocus = .task(task.id) - focusedField = .task(task.id) - selectedTaskID = task.id - return task.id + do { + let task = try store.createTask(title: "", atBeginning: true) + pendingFocus = .task(task.id) + focusedField = .task(task.id) + selectedTaskID = task.id + return task.id + } catch { + presentStoreError(error) + return UUID() + } } func createNewTask() { draggedTaskID = nil visualOrder = nil - let task = store.createTask(title: "") - pendingFocus = .task(task.id) - focusedField = .task(task.id) - selectedTaskID = task.id + do { + let task = try store.createTask(title: "") + pendingFocus = .task(task.id) + focusedField = .task(task.id) + selectedTaskID = task.id + } catch { + presentStoreError(error) + } } // MARK: - Interaction Handlers @@ -115,14 +128,22 @@ extension TaskListView { func updateTitle(_ task: TaskItem, _ title: String) { guard task.title != title else { return } - store.updateWithoutSaving(taskID: task.id, title: title) + do { + try store.updateWithoutSaving(taskID: task.id, title: title) + } catch { + presentStoreError(error) + } } func toggleCompletion(_ task: TaskItem) { - if task.isCompleted { - store.uncomplete(taskID: task.id) - } else { - store.complete(taskID: task.id) + do { + if task.isCompleted { + try store.uncomplete(taskID: task.id) + } else { + try store.complete(taskID: task.id) + } + } catch { + presentStoreError(error) } } @@ -143,19 +164,25 @@ extension TaskListView { func deleteTask(_ task: TaskItem) { let taskID = task.id print("🔴 deleteTask() called for task \(taskID)") - - if selectedTaskID == taskID { - print("🔴 deleteTask() clearing selectedTaskID") - selectedTaskID = nil + do { + try store.delete(taskID: taskID) + if selectedTaskID == taskID { + print("🔴 deleteTask() clearing selectedTaskID") + selectedTaskID = nil + } + print("🔴 deleteTask() completed") + } catch { + presentStoreError(error) } - - store.delete(taskID: taskID) - print("🔴 deleteTask() completed") } func clearCompletedTasks() { for task in completedTasks.reversed() { - store.delete(taskID: task.id) + do { + try store.delete(taskID: task.id) + } catch { + presentStoreError(error) + } } } @@ -265,7 +292,11 @@ extension TaskListView { print( "🟢 endEditing() called for task \(taskID), shouldCreateNewTask: \(shouldCreateNewTask)" ) - store.save() + do { + try store.save() + } catch { + presentStoreError(error) + } let wasLastActiveTask = isLastActiveTask(taskID) let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID) @@ -383,7 +414,11 @@ extension TaskListView { return false } - store.moveTask(taskID: droppedUUID, toIndex: finalIndex) + do { + try store.moveTask(taskID: droppedUUID, toIndex: finalIndex) + } catch { + presentStoreError(error) + } draggedTaskID = nil visualOrder = nil diff --git a/Listless/Extensions/TaskListView+SyncUI.swift b/Listless/Extensions/TaskListView+SyncUI.swift @@ -0,0 +1,40 @@ +import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +extension TaskListView { + @ViewBuilder + var syncErrorBanner: some View { + if let message = syncMonitor.transientErrorMessage { + HStack(spacing: 8) { + Image(systemName: "icloud.slash") + .imageScale(.small) + Text(message) + .font(.caption) + .lineLimit(2) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.horizontal, 12) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + + func openSystemSettings() { + #if os(iOS) + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(settingsURL) + #elseif os(macOS) + if let settingsURL = URL(string: "x-apple.systempreferences:") { + NSWorkspace.shared.open(settingsURL) + } + #endif + } +} diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -1,6 +1,20 @@ import CoreData import Foundation +enum TaskStoreError: LocalizedError { + case fetchFailed(Error) + case saveFailed(Error) + + var errorDescription: String? { + switch self { + case .fetchFailed(let error): + return "Failed to fetch tasks: \(error.localizedDescription)" + case .saveFailed(let error): + return "Failed to save changes: \(error.localizedDescription)" + } + } +} + @MainActor final class TaskStore { private let persistenceController: PersistenceController @@ -12,7 +26,7 @@ final class TaskStore { self.persistenceController = persistenceController } - func fetchTasks() -> [TaskItem] { + func fetchTasks() throws -> [TaskItem] { let request = TaskItem.fetchRequest() request.sortDescriptors = [] @@ -29,17 +43,17 @@ final class TaskStore { return activeTasks + completedTasks } catch { - print("Failed to fetch tasks: \(error)") - return [] + throw TaskStoreError.fetchFailed(error) } } - func createTask(title: String = "", atBeginning: Bool = false) -> TaskItem { + func createTask(title: String = "", atBeginning: Bool = false) throws -> TaskItem { let task = TaskItem(context: context) task.title = title context.processPendingChanges() - let activeTasks = fetchTasks().filter { !$0.isCompleted } + let activeTasks = try fetchTasks().filter { !$0.isCompleted } + if atBeginning { let minOrder = activeTasks.map(\.sortOrder).min() ?? 0 task.sortOrder = minOrder - 1000 @@ -48,20 +62,20 @@ final class TaskStore { task.sortOrder = maxOrder + 1000 } - save() + try save() return task } - func complete(taskID: UUID) { - guard let task = findTask(id: taskID) else { return } + func complete(taskID: UUID) throws { + guard let task = try findTask(id: taskID) else { return } task.isCompleted = true - save() + try save() } - func uncomplete(taskID: UUID) { - guard let task = findTask(id: taskID) else { return } + func uncomplete(taskID: UUID) throws { + guard let task = try findTask(id: taskID) else { return } let restoredSortOrder = task.sortOrder - let activeTasks = fetchTasks().filter { !$0.isCompleted && $0.id != task.id } + let activeTasks = try fetchTasks().filter { !$0.isCompleted && $0.id != task.id } let hasSortOrderConflict = activeTasks.contains { $0.sortOrder == restoredSortOrder } if hasSortOrderConflict { @@ -70,29 +84,29 @@ final class TaskStore { } task.isCompleted = false - save() + try save() } - func update(taskID: UUID, title: String) { - guard let task = findTask(id: taskID) else { return } + func update(taskID: UUID, title: String) throws { + guard let task = try findTask(id: taskID) else { return } task.title = title - save() + try save() } - func updateWithoutSaving(taskID: UUID, title: String) { - guard let task = findTask(id: taskID) else { return } + func updateWithoutSaving(taskID: UUID, title: String) throws { + guard let task = try findTask(id: taskID) else { return } task.title = title // Don't save - will be saved when editing ends } - func delete(taskID: UUID) { - guard let task = findTask(id: taskID) else { return } + func delete(taskID: UUID) throws { + guard let task = try findTask(id: taskID) else { return } context.delete(task) - save() + try save() } - func moveTask(taskID: UUID, toIndex: Int) { - let activeTasks = fetchTasks().filter { !$0.isCompleted } + func moveTask(taskID: UUID, toIndex: Int) throws { + let activeTasks = try fetchTasks().filter { !$0.isCompleted } .sorted { $0.sortOrder < $1.sortOrder } guard let currentIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else { return } @@ -110,10 +124,10 @@ final class TaskStore { task.sortOrder = Int64(index) * 1000 } - save() + try save() } - private func findTask(id: UUID) -> TaskItem? { + private func findTask(id: UUID) throws -> TaskItem? { let request = TaskItem.fetchRequest() request.predicate = NSPredicate(format: "id == %@", id as CVarArg) request.fetchLimit = 1 @@ -121,12 +135,11 @@ final class TaskStore { do { return try context.fetch(request).first } catch { - print("Failed to find task: \(error)") - return nil + throw TaskStoreError.fetchFailed(error) } } - func save() { - persistenceController.save() + func save() throws { + try persistenceController.save() } } diff --git a/Listless/Sync/CloudKitErrorClassifier.swift b/Listless/Sync/CloudKitErrorClassifier.swift @@ -0,0 +1,101 @@ +import CloudKit +import Foundation + +enum SyncAlertAction { + case openSettings +} + +struct SyncAlertItem: Identifiable { + let id = UUID() + let title: String + let message: String + let action: SyncAlertAction? +} + +enum SyncIssue { + case transient(message: String) + case alert(SyncAlertItem) +} + +enum CloudKitErrorClassifier { + static func classify(_ error: Error) -> SyncIssue { + let rootError = unwrap(error) + let nsError = rootError as NSError + + if nsError.domain == CKError.errorDomain, + let ckCode = CKError.Code(rawValue: nsError.code) + { + return classifyCloudKit(code: ckCode) + } + + if nsError.domain == NSCocoaErrorDomain { + return .alert( + SyncAlertItem( + title: "Unable to Save Changes", + message: "Your changes are still local, but syncing encountered an issue. Please try again.", + action: nil + )) + } + + return .alert( + SyncAlertItem( + title: "Sync Error", + message: rootError.localizedDescription, + action: nil + )) + } + + private static func classifyCloudKit(code: CKError.Code) -> SyncIssue { + switch code { + case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, + .zoneBusy, .serverResponseLost, .operationCancelled: + return .transient(message: "Saved locally. iCloud sync will retry automatically.") + + case .notAuthenticated: + return .alert( + SyncAlertItem( + title: "iCloud Sign-In Required", + message: + "Sign in to iCloud in Settings to continue syncing your tasks across devices.", + action: .openSettings + )) + + case .quotaExceeded: + return .alert( + SyncAlertItem( + title: "iCloud Storage Full", + message: + "Free up iCloud storage or upgrade your plan to continue syncing.", + action: .openSettings + )) + + case .permissionFailure, .badContainer, .missingEntitlement: + return .alert( + SyncAlertItem( + title: "iCloud Sync Unavailable", + message: + "This device currently cannot access iCloud for syncing. Check iCloud settings and try again.", + action: .openSettings + )) + + default: + return .alert( + SyncAlertItem( + title: "Sync Error", + message: + "Changes are saved locally, but iCloud sync failed. The app will try again automatically.", + action: nil + )) + } + } + + private static func unwrap(_ error: Error) -> Error { + if let storeError = error as? TaskStoreError { + switch storeError { + case .fetchFailed(let wrappedError), .saveFailed(let wrappedError): + return wrappedError + } + } + return error + } +} diff --git a/Listless/Sync/CloudKitSyncMonitor.swift b/Listless/Sync/CloudKitSyncMonitor.swift @@ -0,0 +1,72 @@ +import CoreData +import Foundation + +@MainActor +final class CloudKitSyncMonitor: ObservableObject { + @Published private(set) var transientErrorMessage: String? + @Published var actionableAlert: SyncAlertItem? + + private var monitoringTask: Task<Void, Never>? + + func startMonitoring(container: NSPersistentCloudKitContainer) { + guard monitoringTask == nil else { return } + + monitoringTask = Task { [weak self] in + guard let self else { return } + + for await notification in NotificationCenter.default.notifications( + named: NSPersistentCloudKitContainer.eventChangedNotification, + object: container + ) { + guard + let event = + notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] + as? NSPersistentCloudKitContainer.Event + else { + continue + } + + if let error = event.error { + self.handle(issue: CloudKitErrorClassifier.classify(error)) + } else if self.isSuccessfulSyncCompletion(event) { + self.transientErrorMessage = nil + } + } + } + } + + func clearActionableAlert() { + actionableAlert = nil + } + + func ingest(error: Error) { + handle(issue: CloudKitErrorClassifier.classify(error)) + } + + private func handle(issue: SyncIssue) { + switch issue { + case .transient(let message): + showTransient(message) + + case .alert(let alert): + actionableAlert = alert + } + } + + private func showTransient(_ message: String) { + transientErrorMessage = message + } + + private func isSuccessfulSyncCompletion(_ event: NSPersistentCloudKitContainer.Event) -> Bool { + guard event.endDate != nil else { return false } + + switch event.type { + case .import, .export: + return true + case .setup: + return false + @unknown default: + return false + } + } +} diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift @@ -49,6 +49,7 @@ final class PersistenceController { static let shared = PersistenceController() let container: NSPersistentCloudKitContainer + let syncMonitor: CloudKitSyncMonitor var viewContext: NSManagedObjectContext { container.viewContext @@ -56,6 +57,7 @@ final class PersistenceController { init(inMemory: Bool = false) { container = NSPersistentCloudKitContainer(name: "Listless") + syncMonitor = CloudKitSyncMonitor() if inMemory { container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") @@ -83,18 +85,22 @@ final class PersistenceController { container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = UpdatedAtMergePolicy() + if !inMemory { + syncMonitor.startMonitoring(container: container) + } + performDataMigrationIfNeeded() } - func save() { + func save() throws { let context = container.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - print("Failed to save context: \(error.localizedDescription)") - } + guard context.hasChanges else { return } + + do { + try context.save() + } catch { + throw TaskStoreError.saveFailed(error) } } diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift @@ -12,7 +12,10 @@ struct ListlessMacApp: App { var body: some Scene { WindowGroup { - TaskListView(store: TaskStore(persistenceController: persistenceController)) + TaskListView( + store: TaskStore(persistenceController: persistenceController), + syncMonitor: persistenceController.syncMonitor + ) .environment(\.managedObjectContext, persistenceController.viewContext) } .windowStyle(.hiddenTitleBar) diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -10,6 +10,7 @@ struct TaskListView: View { @Environment(\.managedObjectContext) var managedObjectContext let store: TaskStore + @ObservedObject var syncMonitor: CloudKitSyncMonitor @FetchRequest( sortDescriptors: [ NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), @@ -28,8 +29,9 @@ struct TaskListView: View { var vStackSpacing: CGFloat { 0 } - init(store: TaskStore = TaskStore()) { + init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) { self.store = store + self.syncMonitor = syncMonitor } func didStartDrag() {} @@ -174,5 +176,35 @@ struct TaskListView: View { .toolbar { platformToolbar } + .overlay(alignment: .top) { + syncErrorBanner + } + .alert( + item: Binding( + get: { syncMonitor.actionableAlert }, + set: { if $0 == nil { syncMonitor.clearActionableAlert() } } + ) + ) { alert in + switch alert.action { + case .openSettings: + return Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text("Open Settings")) { openSystemSettings() }, + secondaryButton: .cancel(Text("OK")) { + syncMonitor.clearActionableAlert() + } + ) + + case .none: + return Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) { + syncMonitor.clearActionableAlert() + } + ) + } + } } } diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift @@ -36,7 +36,11 @@ extension TaskListView { isDragging = false return } - store.moveTask(taskID: draggedID, toIndex: finalIndex) + do { + try store.moveTask(taskID: draggedID, toIndex: finalIndex) + } catch { + presentStoreError(error) + } draggedTaskID = nil visualOrder = nil isDragging = false diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift @@ -6,7 +6,10 @@ struct ListlessiOSApp: App { var body: some Scene { WindowGroup { - TaskListView(store: TaskStore(persistenceController: persistenceController)) + TaskListView( + store: TaskStore(persistenceController: persistenceController), + syncMonitor: persistenceController.syncMonitor + ) .safeAreaInset(edge: .top) { Color.clear.frame(height: 8) } diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -10,6 +10,7 @@ struct TaskListView: View { @Environment(\.managedObjectContext) var managedObjectContext let store: TaskStore + @ObservedObject var syncMonitor: CloudKitSyncMonitor @FetchRequest( sortDescriptors: [ NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), @@ -32,8 +33,9 @@ struct TaskListView: View { var vStackSpacing: CGFloat { 12 } var pullCreateThreshold: CGFloat { 70 } - init(store: TaskStore = TaskStore()) { + init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) { self.store = store + self.syncMonitor = syncMonitor } func didStartDrag() { @@ -84,6 +86,36 @@ struct TaskListView: View { .toolbar { platformToolbar } + .overlay(alignment: .top) { + syncErrorBanner + } + .alert( + item: Binding( + get: { syncMonitor.actionableAlert }, + set: { if $0 == nil { syncMonitor.clearActionableAlert() } } + ) + ) { alert in + switch alert.action { + case .openSettings: + return Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text("Open Settings")) { openSystemSettings() }, + secondaryButton: .cancel(Text("OK")) { + syncMonitor.clearActionableAlert() + } + ) + + case .none: + return Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) { + syncMonitor.clearActionableAlert() + } + ) + } + } } private var taskScrollView: some View { diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -16,13 +16,13 @@ func makeTestStore() -> TaskStore { /// - titles: Optional array of titles; if nil, generates "Task 1", "Task 2", etc. /// - Returns: Tuple of (store, array of created task IDs) @MainActor -func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) -> (TaskStore, [UUID]) { +func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) throws -> (TaskStore, [UUID]) { let store = makeTestStore() var taskIDs: [UUID] = [] for i in 0..<count { let title = titles?[safe: i] ?? "Task \(i + 1)" - let task = store.createTask(title: title) + let task = try store.createTask(title: title) taskIDs.append(task.id) } diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift @@ -12,48 +12,48 @@ struct TaskStoreCompletionTests { @Test("Complete task") func completeTask() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task to complete") + let task = try store.createTask(title: "Task to complete") - store.complete(taskID: task.id) + try store.complete(taskID: task.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == true) } @Test("Uncomplete task") func uncompleteTask() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task") - store.complete(taskID: task.id) + let task = try store.createTask(title: "Task") + try store.complete(taskID: task.id) - store.uncomplete(taskID: task.id) + try store.uncomplete(taskID: task.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == false) } @Test("Complete with invalid ID does nothing") func completeWithInvalidIDDoesNothing() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task") + let task = try store.createTask(title: "Task") let invalidID = UUID() - store.complete(taskID: invalidID) + try store.complete(taskID: invalidID) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == false) } @Test("Uncomplete with invalid ID does nothing") func uncompleteWithInvalidIDDoesNothing() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task") - store.complete(taskID: task.id) + let task = try store.createTask(title: "Task") + try store.complete(taskID: task.id) let invalidID = UUID() - store.uncomplete(taskID: invalidID) + try store.uncomplete(taskID: invalidID) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == true) } @@ -62,15 +62,15 @@ struct TaskStoreCompletionTests { @Test("Completing task updates timestamp") func completingTaskUpdatesTimestamp() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task") + let task = try store.createTask(title: "Task") let originalUpdatedAt = task.updatedAt // Small delay to ensure timestamp difference try await Task.sleep(nanoseconds: 10_000_000) // 10ms - store.complete(taskID: task.id) + try store.complete(taskID: task.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() let updatedTask = tasks.first #expect(updatedTask?.updatedAt ?? Date() > originalUpdatedAt) } @@ -80,13 +80,13 @@ struct TaskStoreCompletionTests { @Test("Active tasks appear before completed tasks") func activeTasksAppearBeforeCompletedTasks() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") - let task3 = store.createTask(title: "Task 3") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") + let task3 = try store.createTask(title: "Task 3") - store.complete(taskID: task2.id) + try store.complete(taskID: task2.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks[0].id == task1.id) #expect(tasks[1].id == task3.id) #expect(tasks[2].id == task2.id) @@ -95,20 +95,20 @@ struct TaskStoreCompletionTests { @Test("Completed tasks sorted by updatedAt") func completedTasksSortedByUpdatedAt() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") - let task3 = store.createTask(title: "Task 3") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") + let task3 = try store.createTask(title: "Task 3") // Complete in specific order with delays - store.complete(taskID: task2.id) + try store.complete(taskID: task2.id) try await Task.sleep(nanoseconds: 10_000_000) // 10ms - store.complete(taskID: task1.id) + try store.complete(taskID: task1.id) try await Task.sleep(nanoseconds: 10_000_000) // 10ms - store.complete(taskID: task3.id) + try store.complete(taskID: task3.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() // All completed, should be sorted by updatedAt (most recently completed first) #expect(tasks[0].id == task3.id) #expect(tasks[1].id == task1.id) @@ -118,46 +118,46 @@ struct TaskStoreCompletionTests { @Test("Toggle completion multiple times") func toggleCompletionMultipleTimes() async throws { let store = makeTestStore() - let task = store.createTask(title: "Task") + let task = try store.createTask(title: "Task") - store.complete(taskID: task.id) - var tasks = store.fetchTasks() + try store.complete(taskID: task.id) + var tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == true) - store.uncomplete(taskID: task.id) - tasks = store.fetchTasks() + try store.uncomplete(taskID: task.id) + tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == false) - store.complete(taskID: task.id) - tasks = store.fetchTasks() + try store.complete(taskID: task.id) + tasks = try store.fetchTasks() #expect(tasks.first?.isCompleted == true) } @Test("Complete all tasks") func completeAllTasks() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 5) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 5) for id in taskIDs { - store.complete(taskID: id) + try store.complete(taskID: id) } - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.allSatisfy { $0.isCompleted }) #expect(tasks.count == 5) } @Test("Uncomplete restores previous sortOrder when no active conflict") func uncompleteRestoresPreviousSortOrderWhenNoConflict() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) let taskToRestoreID = taskIDs[1] - let originalSortOrder = store.fetchTasks().first { $0.id == taskToRestoreID }?.sortOrder + let originalSortOrder = try store.fetchTasks().first { $0.id == taskToRestoreID }?.sortOrder #expect(originalSortOrder != nil) - store.complete(taskID: taskToRestoreID) - store.uncomplete(taskID: taskToRestoreID) + try store.complete(taskID: taskToRestoreID) + try store.uncomplete(taskID: taskToRestoreID) - let activeTasks = store.fetchTasks().filter { !$0.isCompleted } + let activeTasks = try store.fetchTasks().filter { !$0.isCompleted } let restoredTask = activeTasks.first { $0.id == taskToRestoreID } #expect(restoredTask != nil) @@ -168,17 +168,17 @@ struct TaskStoreCompletionTests { @Test("Uncomplete appends task when restored sortOrder conflicts with active task") func uncompleteAppendsWhenRestoredSortOrderConflicts() async throws { let store = makeTestStore() - let activeTask = store.createTask(title: "Active task") - let completedTask = store.createTask(title: "Completed task") + let activeTask = try store.createTask(title: "Active task") + let completedTask = try store.createTask(title: "Completed task") - store.complete(taskID: completedTask.id) - store.moveTask(taskID: activeTask.id, toIndex: 0) + try store.complete(taskID: completedTask.id) + try store.moveTask(taskID: activeTask.id, toIndex: 0) completedTask.sortOrder = activeTask.sortOrder - store.save() + try store.save() - store.uncomplete(taskID: completedTask.id) + try store.uncomplete(taskID: completedTask.id) - let activeTasks = store.fetchTasks().filter { !$0.isCompleted } + let activeTasks = try store.fetchTasks().filter { !$0.isCompleted } .sorted { $0.sortOrder < $1.sortOrder } let lastActiveTask = activeTasks.last diff --git a/Tests/Unit/TaskStoreEdgeCaseTests.swift b/Tests/Unit/TaskStoreEdgeCaseTests.swift @@ -14,9 +14,9 @@ struct TaskStoreEdgeCaseTests { func taskWithEmptyTitle() async throws { let store = makeTestStore() - let task = store.createTask(title: "") + let task = try store.createTask(title: "") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == "") } @@ -25,9 +25,9 @@ struct TaskStoreEdgeCaseTests { let store = makeTestStore() let longTitle = String(repeating: "A", count: 10_000) - let task = store.createTask(title: longTitle) + let task = try store.createTask(title: longTitle) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title.count == 10_000) } @@ -36,9 +36,9 @@ struct TaskStoreEdgeCaseTests { let store = makeTestStore() let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()" - let task = store.createTask(title: specialTitle) + let task = try store.createTask(title: specialTitle) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == specialTitle) } @@ -47,9 +47,9 @@ struct TaskStoreEdgeCaseTests { let store = makeTestStore() let multilineTitle = "Line 1\nLine 2\tTabbed" - let task = store.createTask(title: multilineTitle) + let task = try store.createTask(title: multilineTitle) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == multilineTitle) } @@ -61,10 +61,10 @@ struct TaskStoreEdgeCaseTests { let count = 100 for i in 0..<count { - _ = store.createTask(title: "Task \(i)") + _ = try store.createTask(title: "Task \(i)") } - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == count) } @@ -74,15 +74,15 @@ struct TaskStoreEdgeCaseTests { var taskIDs: [UUID] = [] for i in 0..<50 { - let task = store.createTask(title: "Task \(i)") + let task = try store.createTask(title: "Task \(i)") taskIDs.append(task.id) } for id in taskIDs { - store.delete(taskID: id) + try store.delete(taskID: id) } - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.isEmpty) } @@ -90,15 +90,15 @@ struct TaskStoreEdgeCaseTests { @Test("Create task after completing all tasks") func createTaskAfterCompletingAllTasks() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) for id in taskIDs { - store.complete(taskID: id) + try store.complete(taskID: id) } - let newTask = store.createTask(title: "New task") + let newTask = try store.createTask(title: "New task") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() let activeTasks = tasks.filter { !$0.isCompleted } #expect(activeTasks.count == 1) #expect(activeTasks[0].id == newTask.id) @@ -107,13 +107,13 @@ struct TaskStoreEdgeCaseTests { @Test("Rapid updates to same task") func rapidUpdatesToSameTask() async throws { let store = makeTestStore() - let task = store.createTask(title: "Original") + let task = try store.createTask(title: "Original") for i in 0..<10 { - store.update(taskID: task.id, title: "Update \(i)") + try store.update(taskID: task.id, title: "Update \(i)") } - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == "Update 9") } @@ -121,29 +121,29 @@ struct TaskStoreEdgeCaseTests { @Test("Store with only completed tasks") func storeWithOnlyCompletedTasks() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 5) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 5) for id in taskIDs { - store.complete(taskID: id) + try store.complete(taskID: id) } - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.allSatisfy { $0.isCompleted }) #expect(tasks.count == 5) } @Test("SortOrder after completing all tasks") func sortOrderAfterCompletingAllTasks() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) for id in taskIDs { - store.complete(taskID: id) + try store.complete(taskID: id) } - let newTask1 = store.createTask(title: "New 1") - let newTask2 = store.createTask(title: "New 2") + let newTask1 = try store.createTask(title: "New 1") + let newTask2 = try store.createTask(title: "New 2") - let activeTasks = store.fetchTasks().filter { !$0.isCompleted } + let activeTasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(activeTasks[0].id == newTask1.id) #expect(activeTasks[1].id == newTask2.id) #expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder) @@ -151,12 +151,12 @@ struct TaskStoreEdgeCaseTests { @Test("Uncompleting task moves it back to active") func uncompletingTaskMovesItBackToActive() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) - store.complete(taskID: taskIDs[1]) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) + try store.complete(taskID: taskIDs[1]) - store.uncomplete(taskID: taskIDs[1]) + try store.uncomplete(taskID: taskIDs[1]) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() let activeTasks = tasks.filter { !$0.isCompleted } #expect(activeTasks.count == 3) #expect(activeTasks.contains { $0.id == taskIDs[1] }) @@ -165,17 +165,17 @@ struct TaskStoreEdgeCaseTests { @Test("Uncomplete legacy sortOrder zero conflict appends to end") func uncompleteLegacyZeroConflictAppendsToEnd() async throws { let store = makeTestStore() - let activeTask = store.createTask(title: "Active") - let completedTask = store.createTask(title: "Completed") + let activeTask = try store.createTask(title: "Active") + let completedTask = try store.createTask(title: "Completed") activeTask.sortOrder = 0 - store.complete(taskID: completedTask.id) + try store.complete(taskID: completedTask.id) completedTask.sortOrder = 0 - store.save() + try store.save() - store.uncomplete(taskID: completedTask.id) + try store.uncomplete(taskID: completedTask.id) - let activeTasks = store.fetchTasks().filter { !$0.isCompleted } + let activeTasks = try store.fetchTasks().filter { !$0.isCompleted } .sorted { $0.sortOrder < $1.sortOrder } #expect(activeTasks.count == 2) #expect(activeTasks[0].id == activeTask.id) diff --git a/Tests/Unit/TaskStoreOrderingTests.swift b/Tests/Unit/TaskStoreOrderingTests.swift @@ -13,11 +13,11 @@ struct TaskStoreOrderingTests { func initialSortOrderHasThousandPointGaps() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") - let task3 = store.createTask(title: "Task 3") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") + let task3 = try store.createTask(title: "Task 3") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() // All tasks are active, so they should be the first 3 #expect(tasks.count == 3) @@ -42,12 +42,12 @@ struct TaskStoreOrderingTests { (from: 2, to: 1), ]) func moveTaskToDifferentPositions(from: Int, to: Int) async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) let taskToMove = taskIDs[from] - store.moveTask(taskID: taskToMove, toIndex: to) + try store.moveTask(taskID: taskToMove, toIndex: to) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[to].id == taskToMove) } @@ -55,11 +55,11 @@ struct TaskStoreOrderingTests { @Test("Moving maintains 1000-point gaps") func movingMaintainsThousandPointGaps() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 4) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 4) - store.moveTask(taskID: taskIDs[0], toIndex: 2) + try store.moveTask(taskID: taskIDs[0], toIndex: 2) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[0].sortOrder == 0) #expect(tasks[1].sortOrder == 1000) #expect(tasks[2].sortOrder == 2000) @@ -68,12 +68,12 @@ struct TaskStoreOrderingTests { @Test("Move task to same index does nothing") func moveTaskToSameIndexDoesNothing() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) - let originalTasks = store.fetchTasks().filter { !$0.isCompleted } + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) + let originalTasks = try store.fetchTasks().filter { !$0.isCompleted } - store.moveTask(taskID: taskIDs[1], toIndex: 1) + try store.moveTask(taskID: taskIDs[1], toIndex: 1) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[0].id == originalTasks[0].id) #expect(tasks[1].id == originalTasks[1].id) #expect(tasks[2].id == originalTasks[2].id) @@ -83,13 +83,13 @@ struct TaskStoreOrderingTests { @Test("Move with invalid ID does nothing") func moveWithInvalidIDDoesNothing() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) - let originalTasks = store.fetchTasks().filter { !$0.isCompleted } + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) + let originalTasks = try store.fetchTasks().filter { !$0.isCompleted } let invalidID = UUID() - store.moveTask(taskID: invalidID, toIndex: 0) + try store.moveTask(taskID: invalidID, toIndex: 0) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[0].id == originalTasks[0].id) #expect(tasks[1].id == originalTasks[1].id) #expect(tasks[2].id == originalTasks[2].id) @@ -97,21 +97,21 @@ struct TaskStoreOrderingTests { @Test("Move to negative index clamps to 0") func moveToNegativeIndexClampsToZero() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) - store.moveTask(taskID: taskIDs[2], toIndex: -5) + try store.moveTask(taskID: taskIDs[2], toIndex: -5) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[0].id == taskIDs[2]) } @Test("Move to out-of-bounds index clamps to end") func moveToOutOfBoundsIndexClampsToEnd() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) - store.moveTask(taskID: taskIDs[0], toIndex: 999) + try store.moveTask(taskID: taskIDs[0], toIndex: 999) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[2].id == taskIDs[0]) } @@ -119,12 +119,12 @@ struct TaskStoreOrderingTests { @Test("Moving only affects active tasks") func movingOnlyAffectsActiveTasks() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 4) - store.complete(taskID: taskIDs[3]) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 4) + try store.complete(taskID: taskIDs[3]) - store.moveTask(taskID: taskIDs[0], toIndex: 2) + try store.moveTask(taskID: taskIDs[0], toIndex: 2) - let allTasks = store.fetchTasks() + let allTasks = try store.fetchTasks() let activeTasks = allTasks.filter { !$0.isCompleted } let completedTasks = allTasks.filter { $0.isCompleted } @@ -135,13 +135,13 @@ struct TaskStoreOrderingTests { @Test("Moving completed task does nothing") func movingCompletedTaskDoesNothing() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 3) - store.complete(taskID: taskIDs[0]) - let originalTasks = store.fetchTasks() + let (store, taskIDs) = try makeTestStoreWithTasks(count: 3) + try store.complete(taskID: taskIDs[0]) + let originalTasks = try store.fetchTasks() - store.moveTask(taskID: taskIDs[0], toIndex: 1) + try store.moveTask(taskID: taskIDs[0], toIndex: 1) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks[0].id == originalTasks[0].id) #expect(tasks[1].id == originalTasks[1].id) #expect(tasks[2].id == originalTasks[2].id) @@ -152,11 +152,11 @@ struct TaskStoreOrderingTests { @Test("Move single task does nothing") func moveSingleTaskDoesNothing() async throws { let store = makeTestStore() - let task = store.createTask(title: "Only task") + let task = try store.createTask(title: "Only task") - store.moveTask(taskID: task.id, toIndex: 0) + try store.moveTask(taskID: task.id, toIndex: 0) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == 1) #expect(tasks[0].id == task.id) } @@ -166,20 +166,20 @@ struct TaskStoreOrderingTests { let store = makeTestStore() let randomID = UUID() - store.moveTask(taskID: randomID, toIndex: 0) + try store.moveTask(taskID: randomID, toIndex: 0) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.isEmpty) } @Test("Multiple moves maintain order") func multipleMoveMaintainOrder() async throws { - let (store, taskIDs) = makeTestStoreWithTasks(count: 4) + let (store, taskIDs) = try makeTestStoreWithTasks(count: 4) - store.moveTask(taskID: taskIDs[0], toIndex: 3) - store.moveTask(taskID: taskIDs[2], toIndex: 0) + try store.moveTask(taskID: taskIDs[0], toIndex: 3) + try store.moveTask(taskID: taskIDs[2], toIndex: 0) - let tasks = store.fetchTasks().filter { !$0.isCompleted } + let tasks = try store.fetchTasks().filter { !$0.isCompleted } #expect(tasks[0].id == taskIDs[2]) #expect(tasks[3].id == taskIDs[0]) } diff --git a/Tests/Unit/TaskStoreTests.swift b/Tests/Unit/TaskStoreTests.swift @@ -13,7 +13,7 @@ struct TaskStoreTests { func createTaskWithEmptyTitle() async throws { let store = makeTestStore() - let task = store.createTask() + let task = try store.createTask() #expect(task.title == "") #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) @@ -25,7 +25,7 @@ struct TaskStoreTests { func createTaskWithTitle() async throws { let store = makeTestStore() - let task = store.createTask(title: "Buy groceries") + let task = try store.createTask(title: "Buy groceries") #expect(task.title == "Buy groceries") #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) @@ -35,9 +35,9 @@ struct TaskStoreTests { func createMultipleTasksWithUniqueIDs() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") - let task3 = store.createTask(title: "Task 3") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") + let task3 = try store.createTask(title: "Task 3") #expect(task1.id != task2.id) #expect(task2.id != task3.id) @@ -49,7 +49,7 @@ struct TaskStoreTests { let store = makeTestStore() let beforeCreate = Date() - let task = store.createTask(title: "Test") + let task = try store.createTask(title: "Test") let afterCreate = Date() #expect(task.createdAt >= beforeCreate) @@ -64,7 +64,7 @@ struct TaskStoreTests { func fetchTasksFromEmptyStore() async throws { let store = makeTestStore() - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.isEmpty) } @@ -72,10 +72,10 @@ struct TaskStoreTests { @Test("Fetch tasks returns created tasks") func fetchTasksReturnsCreatedTasks() async throws { let store = makeTestStore() - _ = store.createTask(title: "Task 1") - _ = store.createTask(title: "Task 2") + _ = try store.createTask(title: "Task 1") + _ = try store.createTask(title: "Task 2") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == 2) #expect(tasks[0].title == "Task 1") @@ -87,34 +87,34 @@ struct TaskStoreTests { @Test("Update task title") func updateTaskTitle() async throws { let store = makeTestStore() - let task = store.createTask(title: "Original") + let task = try store.createTask(title: "Original") - store.update(taskID: task.id, title: "Updated") + try store.update(taskID: task.id, title: "Updated") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == "Updated") } @Test("Update task title without saving") func updateTaskTitleWithoutSaving() async throws { let store = makeTestStore() - let task = store.createTask(title: "Original") + let task = try store.createTask(title: "Original") - store.updateWithoutSaving(taskID: task.id, title: "Updated") + try store.updateWithoutSaving(taskID: task.id, title: "Updated") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.first?.title == "Updated") } @Test("Update with invalid ID does nothing") func updateWithInvalidIDDoesNothing() async throws { let store = makeTestStore() - _ = store.createTask(title: "Task 1") + _ = try store.createTask(title: "Task 1") let invalidID = UUID() - store.update(taskID: invalidID, title: "Should not exist") + try store.update(taskID: invalidID, title: "Should not exist") - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == 1) #expect(tasks.first?.title == "Task 1") } @@ -124,12 +124,12 @@ struct TaskStoreTests { @Test("Delete task") func deleteTask() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") - store.delete(taskID: task1.id) + try store.delete(taskID: task1.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == 1) #expect(tasks.first?.id == task2.id) } @@ -137,25 +137,25 @@ struct TaskStoreTests { @Test("Delete all tasks") func deleteAllTasks() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") - store.delete(taskID: task1.id) - store.delete(taskID: task2.id) + try store.delete(taskID: task1.id) + try store.delete(taskID: task2.id) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.isEmpty) } @Test("Delete with invalid ID does nothing") func deleteWithInvalidIDDoesNothing() async throws { let store = makeTestStore() - _ = store.createTask(title: "Task 1") + _ = try store.createTask(title: "Task 1") let invalidID = UUID() - store.delete(taskID: invalidID) + try store.delete(taskID: invalidID) - let tasks = store.fetchTasks() + let tasks = try store.fetchTasks() #expect(tasks.count == 1) } @@ -164,10 +164,10 @@ struct TaskStoreTests { @Test("Task IDs persist across fetches") func taskIDsPersistAcrossFetches() async throws { let store = makeTestStore() - let task = store.createTask(title: "Test") + let task = try store.createTask(title: "Test") let originalID = task.id - let fetchedTasks = store.fetchTasks() + let fetchedTasks = try store.fetchTasks() let fetchedID = fetchedTasks.first?.id #expect(fetchedID == originalID) @@ -177,9 +177,9 @@ struct TaskStoreTests { func createTaskIncrementsSortOrder() async throws { let store = makeTestStore() - let task1 = store.createTask(title: "Task 1") - let task2 = store.createTask(title: "Task 2") - let task3 = store.createTask(title: "Task 3") + let task1 = try store.createTask(title: "Task 1") + let task2 = try store.createTask(title: "Task 2") + let task3 = try store.createTask(title: "Task 3") #expect(task2.sortOrder > task1.sortOrder) #expect(task3.sortOrder > task2.sortOrder)