From 664eada8a24a2d10fc0b492a83e356b5abd473ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 15 Sep 2026 00:49:41 -0700 Subject: [PATCH] fix: stabilize reminder indexes across fetches --- CHANGELOG.md | 1 + Sources/RemindCore/ReminderFilter.swift | 12 +++++++---- Tests/RemindCoreTests/IDResolverTests.swift | 24 +++++++++++++++++++++ docs/commands.md | 1 + 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6c30e..f0de3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Keep numeric reminder indexes stable across reordered EventKit fetches by breaking equal due-date/title sort ties with the reminder ID. - Reject impossible ISO dates, malformed offsets, and trailing date text; select explicit field order so day-first inputs cannot silently become another date while retaining unambiguous legacy spellings. ## 0.3.7 - 2026-09-13 diff --git a/Sources/RemindCore/ReminderFilter.swift b/Sources/RemindCore/ReminderFilter.swift index 40ea899..1842638 100644 --- a/Sources/RemindCore/ReminderFilter.swift +++ b/Sources/RemindCore/ReminderFilter.swift @@ -97,17 +97,21 @@ public enum ReminderFiltering { reminders.sorted { lhs, rhs in switch (lhs.dueDate, rhs.dueDate) { case (nil, nil): - return lhs.title < rhs.title + break case (nil, _?): return false case (_?, nil): return true case (let left?, let right?): - if left == right { - return lhs.title < rhs.title + if left != right { + return left < right } - return left < right } + if lhs.title != rhs.title { + return lhs.title < rhs.title + } + // EventKit fetch order can change between displaying and resolving a numeric index. + return lhs.id < rhs.id } } } diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift index 30fe1a6..0252121 100644 --- a/Tests/RemindCoreTests/IDResolverTests.swift +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -45,6 +45,30 @@ struct IDResolverTests { #expect(resolved.first?.title == "Second") } + @Test("Numeric indexes survive reordered fetches with tied sort keys", arguments: [true, false]) + func stableIndexesWithTiedSortKeys(hasDueDate: Bool) throws { + let items = ["aaaa-1111", "bbbb-2222", "cccc-3333"].map { id in + ReminderItem( + id: id, + title: "Pay invoice", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: hasDueDate ? Date(timeIntervalSince1970: 1_700_000_000) : nil, + listID: "synthetic", + listName: "Synthetic" + ) + } + let displayed = ReminderFiltering.sort(items) + let fetched = Array(items.reversed()) + #expect(ReminderFiltering.sort(fetched).map(\.id) == displayed.map(\.id)) + for (index, reminder) in displayed.enumerated() { + let resolved = try IDResolver.resolve([String(index + 1)], from: fetched, numericFrom: fetched) + #expect(resolved.first?.id == reminder.id) + } + } + @Test("Reject out-of-range indexes without overflow", arguments: [Int.min, -1, 0, 3, Int.max]) func rejectInvalidIndex(_ index: Int) { let input = String(index) diff --git a/docs/commands.md b/docs/commands.md index 98dec10..d1b4afd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -75,6 +75,7 @@ remindctl edit 4A83 --no-repeat `edit`, `complete`, and `delete` accept indexes from the current default listing or ID prefixes. Numeric indexes must be positive and within the current view; out-of-range values return an error. +Reminders sort by due date, then title, then stable ID so equal dates and titles do not make numeric targets depend on EventKit's fetch order. Use IDs when reminders may be added, removed, or edited between commands. If `add`, `edit`, or `complete` reports `Reminder is missing a calendar`, EventKit saved the reminder but returned it without its list. Check Reminders.app before retrying; this error does not roll back the saved change.