listless

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

commit 5e60a3f72a412d7a17573e4a868960200644ee84
parent f3d462b32900fe6bdcbfb2889aec11e1db1a8fb1
Author: Michael Camilleri <[email protected]>
Date:   Sun,  8 Mar 2026 03:02:31 +0900

Change completion ordering approach

Completion ordering currently depends on the time at which a `TaskItem`
was updated. During personal testing, this does not appear to be
deterministic. This commit adds a `completedOrder` column to the
database which should be deterministic. A migration for existing users
has not been prepared but since the only person using the app currently
is me, this is not considered a problem.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MDocs/Schema.md | 2+-
MListless/Extensions/TaskListView+Logic.swift | 2+-
MListless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents | 2+-
MListless/Models/TaskItem.swift | 8+++++---
MListless/Models/TaskStore.swift | 19++++++++++++++-----
MListless/Sync/PersistenceController.swift | 28----------------------------
MListlessMac/Views/TaskListView.swift | 5+----
MListlessWatch/Views/TaskListView.swift | 2+-
MListlessiOS/Views/TaskListView.swift | 5+----
MTests/Unit/TaskStoreCompletionTests.swift | 12++++--------
10 files changed, 29 insertions(+), 56 deletions(-)

diff --git a/Docs/Schema.md b/Docs/Schema.md @@ -3,9 +3,9 @@ ## TaskItem - `id: UUID` — stable primary key generated at creation; used as the Boutique `ItemIdentifier`. - `title: String` — UTF-8 text up to 2,048 characters; may be empty or contain only whitespace to represent placeholder rows. Preserve user-entered blank lines. -- `isCompleted: Bool` — toggled freely; set `false` when unmarking a task so previously archived items can return to the active bucket. - `createdAt: Date` — timestamp used for deterministic ordering when indexes match. - `updatedAt: Date` — refreshed on every mutation to aid conflict resolution between local cache and iCloud. +- `completedOrder: Int64` — monotonically increasing value assigned when a task is completed; higher values sort first (most recently completed at top). Reset to 0 when uncompleted. A value of 0 means the task is active; a value greater than 0 means it is completed. ## Storage & Sync - Boutique `Store<TaskItem>` configured with `StorageEngine.cloudKit(appGroup: "net.inqk.listless")` keeps data synced across macOS, iOS, and iPadOS. diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -21,7 +21,7 @@ extension TaskListViewProtocol { var completedTasks: [TaskItem] { Array(tasks.filter { !$0.isDeleted && $0.isCompleted }) - .sorted { $0.updatedAt > $1.updatedAt } + .sorted { $0.completedOrder > $1.completedOrder } } var allTasksInDisplayOrder: [TaskItem] { diff --git a/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents b/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="24G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier=""> <entity name="TaskItem" representedClassName="TaskItem" syncable="YES"> + <attribute name="completedOrder" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="createdAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/> <attribute name="id" attributeType="UUID" optional="YES" usesScalarValueType="NO"/> - <attribute name="isCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="sortOrder" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" indexed="YES"/> <attribute name="title" attributeType="String" defaultValueString=""/> <attribute name="updatedAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/> diff --git a/Listless/Models/TaskItem.swift b/Listless/Models/TaskItem.swift @@ -10,18 +10,20 @@ public class TaskItem: NSManagedObject, Identifiable { static let id = key(\TaskItem.id as KeyPath<TaskItem, UUID>) static let title = key(\TaskItem.title) - static let isCompleted = key(\TaskItem.isCompleted) static let createdAt = key(\TaskItem.createdAt) static let updatedAt = key(\TaskItem.updatedAt) static let sortOrder = key(\TaskItem.sortOrder) + static let completedOrder = key(\TaskItem.completedOrder) } @NSManaged public var id: UUID @NSManaged public var title: String - @NSManaged public var isCompleted: Bool @NSManaged public var createdAt: Date @NSManaged public var updatedAt: Date @NSManaged public var sortOrder: Int64 + @NSManaged public var completedOrder: Int64 + + public var isCompleted: Bool { completedOrder > 0 } @nonobjc public class func fetchRequest() -> NSFetchRequest<TaskItem> { return NSFetchRequest<TaskItem>(entityName: "TaskItem") @@ -32,9 +34,9 @@ public class TaskItem: NSManagedObject, Identifiable { setPrimitiveValue(UUID(), forKey: Keys.id) setPrimitiveValue(Date(), forKey: Keys.createdAt) setPrimitiveValue(Date(), forKey: Keys.updatedAt) - setPrimitiveValue(false, forKey: Keys.isCompleted) setPrimitiveValue("", forKey: Keys.title) setPrimitiveValue(0, forKey: Keys.sortOrder) + setPrimitiveValue(0, forKey: Keys.completedOrder) } public override func willSave() { diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -29,15 +29,15 @@ final class TaskStore { func fetchTasks() throws -> [TaskItem] { do { let activeRequest = TaskItem.fetchRequest() - activeRequest.predicate = NSPredicate(format: "isCompleted == NO") + activeRequest.predicate = NSPredicate(format: "completedOrder == 0") activeRequest.sortDescriptors = [ NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true) ] let completedRequest = TaskItem.fetchRequest() - completedRequest.predicate = NSPredicate(format: "isCompleted == YES") + completedRequest.predicate = NSPredicate(format: "completedOrder > 0") completedRequest.sortDescriptors = [ - NSSortDescriptor(keyPath: \TaskItem.updatedAt, ascending: false) + NSSortDescriptor(keyPath: \TaskItem.completedOrder, ascending: false) ] let activeTasks = try context.fetch(activeRequest) @@ -73,7 +73,16 @@ final class TaskStore { func complete(taskID: UUID) throws { guard let task = try findTask(id: taskID) else { return } - task.isCompleted = true + + let request = TaskItem.fetchRequest() + request.predicate = NSPredicate(format: "completedOrder > 0") + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \TaskItem.completedOrder, ascending: false) + ] + request.fetchLimit = 1 + let maxOrder = (try context.fetch(request).first)?.completedOrder ?? 0 + + task.completedOrder = maxOrder + 1 try save() } @@ -88,7 +97,7 @@ final class TaskStore { task.sortOrder = maxSortOrder + 1000 } - task.isCompleted = false + task.completedOrder = 0 try save() } diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift @@ -100,7 +100,6 @@ final class PersistenceController { syncMonitor.startMonitoring(container: container) } - performDataMigrationIfNeeded() } func save() throws { @@ -114,31 +113,4 @@ final class PersistenceController { throw TaskStoreError.saveFailed(error) } } - - private func performDataMigrationIfNeeded() { - let hasRun = UserDefaults.standard.bool(forKey: "didMigrateSortOrder_v1") - guard !hasRun else { return } - - let context = container.viewContext - let request = TaskItem.fetchRequest() - request.sortDescriptors = [ - NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), - NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true), - ] - - guard let tasks = try? context.fetch(request) else { return } - - let activeTasks = tasks.filter { !$0.isCompleted } - - // Only set sortOrder for active tasks - for (index, task) in activeTasks.enumerated() { - task.sortOrder = Int64(index) * 1000 - } - - // Completed tasks: sortOrder can be 0 (not used) - // They'll be sorted by updatedAt instead - - try? context.save() - UserDefaults.standard.set(true, forKey: "didMigrateSortOrder_v1") - } } diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -23,10 +23,7 @@ struct TaskListView: View, TaskListViewProtocol { let store: TaskStore @ObservedObject var syncMonitor: CloudKitSyncMonitor @FetchRequest( - sortDescriptors: [ - NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), - NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true), - ], + sortDescriptors: [], animation: .default ) var tasks: FetchedResults<TaskItem> diff --git a/ListlessWatch/Views/TaskListView.swift b/ListlessWatch/Views/TaskListView.swift @@ -9,7 +9,6 @@ struct TaskListView: View { @FetchRequest( sortDescriptors: [ - SortDescriptor(\TaskItem.isCompleted, order: .forward), SortDescriptor(\TaskItem.sortOrder, order: .forward), ], animation: .default @@ -19,6 +18,7 @@ struct TaskListView: View { var body: some View { let activeTasks = tasks.filter { !$0.isCompleted } let completedTasks = tasks.filter { $0.isCompleted } + .sorted { $0.completedOrder > $1.completedOrder } NavigationStack { Group { diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -31,10 +31,7 @@ struct TaskListView: View, TaskListViewProtocol { let store: TaskStore @ObservedObject var syncMonitor: CloudKitSyncMonitor @FetchRequest( - sortDescriptors: [ - NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), - NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true), - ], + sortDescriptors: [], animation: .default ) var tasks: FetchedResults<TaskItem> diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift @@ -92,24 +92,20 @@ struct TaskStoreCompletionTests { #expect(tasks[2].id == task2.id) } - @Test("Completed tasks sorted by updatedAt") - func completedTasksSortedByUpdatedAt() async throws { + @Test("Completed tasks sorted by completedOrder") + func completedTasksSortedByCompletedOrder() async throws { let store = makeTestStore() 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 + // Complete in specific order try store.complete(taskID: task2.id) - try await Task.sleep(nanoseconds: 10_000_000) // 10ms - try store.complete(taskID: task1.id) - try await Task.sleep(nanoseconds: 10_000_000) // 10ms - try store.complete(taskID: task3.id) let tasks = try store.fetchTasks() - // All completed, should be sorted by updatedAt (most recently completed first) + // All completed, should be sorted by completedOrder (most recently completed first) #expect(tasks[0].id == task3.id) #expect(tasks[1].id == task1.id) #expect(tasks[2].id == task2.id)