Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions Sources/RemindCore/ReminderFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
24 changes: 24 additions & 0 deletions Tests/RemindCoreTests/IDResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down