← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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)