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:
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)