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