From 9e6abd2af5771d41a704e5fcee60c4e49572d462 Mon Sep 17 00:00:00 2001 From: udondan Date: Sun, 13 Sep 2026 16:04:11 +0200 Subject: [PATCH 1/2] feat: always include completionDate in JSON output Previously the completionDate key was omitted entirely for incomplete reminders instead of being emitted as null, making the field unreliable to consume from JSON output. --- Sources/RemindersLibrary/EKReminder+Encodable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index b4c7cea..0aa5ea5 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -38,7 +38,7 @@ extension EKReminder: @retroactive Encodable { // url field is nil // https://developer.apple.com/forums/thread/128140 try container.encodeIfPresent(self.url, forKey: .url) - try container.encodeIfPresent(format(self.completionDate), forKey: .completionDate) + try container.encode(format(self.completionDate), forKey: .completionDate) for alarm in self.alarms ?? [] { if let location = alarm.structuredLocation { From 19eae5480c22f5684184f7bb063ddd38379deaef Mon Sep 17 00:00:00 2001 From: udondan Date: Sun, 13 Sep 2026 16:04:18 +0200 Subject: [PATCH 2/2] feat: add --completed-since filter to show and show-all Lets callers scope completed reminders to a date range, e.g. answering "what got completed this week", by filtering on completionDate with an inclusive lower bound. Requires --only-completed or --include-completed, since there's nothing to filter otherwise. --- README.md | 7 +++++- Sources/RemindersLibrary/CLI.swift | 17 +++++++++++++- Sources/RemindersLibrary/Reminders.swift | 24 ++++++++++++++------ Tests/RemindersTests/FilterTests.swift | 29 +++++++++++++++++++++--- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 154eb4d..8d8c044 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,8 @@ $ reminders show-all --search groceries $ reminders show-all --list Soon --list Work 0: Ship reminders-cli (2 days ago) 1: Contribute to open source (in 3 hours) +$ reminders show-all --include-completed --completed-since monday +5: Write README ``` `--priority` and `--list` may be repeated to match any of several values. `--list` restricts which @@ -203,7 +205,10 @@ date without a time includes the whole local day for `--due-before` (matching th behavior above), while `--due-after` uses the parsed date's own start as an inclusive lower bound. `--overdue` is independent of, and combinable with, `--due-date`/`--include-overdue`. `--no-due-date` cannot be combined with `--due-date`, `--due-before`, `--due-after`, `--overdue`, or -`--include-overdue`, since those all require a due date to compare against. +`--include-overdue`, since those all require a due date to compare against. `--completed-since` +accepts the same natural-language dates as `--due-date`/`--due-after`, uses an inclusive lower +bound against each reminder's completion time, and requires `--only-completed` or +`--include-completed`, since there's nothing to filter otherwise. #### Sort reminders diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index bdcd769..1dd4dbe 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -65,6 +65,9 @@ private struct ShowAll: ParsableCommand { @Option(help: "Only show reminders from this list; repeat to specify multiple") var list: [String] = [] + @Option(help: "Only show completed reminders completed on or after this date") + var completedSince: DateComponents? + @Option( name: .shortAndLong, help: "format, either of 'plain' or 'json'") @@ -82,6 +85,10 @@ private struct ShowAll: ParsableCommand { throw ValidationError( "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") } + if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { + throw ValidationError( + "--completed-since requires --only-completed or --include-completed") + } } func run() { @@ -96,7 +103,7 @@ private struct ShowAll: ParsableCommand { dueOn: self.dueDate, includeOverdue: self.includeOverdue, overdue: self.overdue, dueBefore: self.dueBefore, dueAfter: self.dueAfter, noDueDate: self.noDueDate, priorities: self.priority, search: self.search, - lists: self.list, + lists: self.list, completedSince: self.completedSince, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) } } @@ -152,6 +159,9 @@ private struct Show: ParsableCommand { @Option(help: "Only show reminders whose title or notes contain this text (case-insensitive)") var search: String? + @Option(help: "Only show completed reminders completed on or after this date") + var completedSince: DateComponents? + @Option( name: .shortAndLong, help: "format, either of 'plain' or 'json'") @@ -169,6 +179,10 @@ private struct Show: ParsableCommand { throw ValidationError( "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") } + if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { + throw ValidationError( + "--completed-since requires --only-completed or --include-completed") + } } func run() { @@ -183,6 +197,7 @@ private struct Show: ParsableCommand { withNameOrId: self.listNameOrId, dueOn: self.dueDate, includeOverdue: self.includeOverdue, overdue: self.overdue, dueBefore: self.dueBefore, dueAfter: self.dueAfter, noDueDate: self.noDueDate, priorities: self.priority, search: self.search, + completedSince: self.completedSince, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) } } diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index d6d9127..4a330ee 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -63,9 +63,10 @@ private func format(_ reminder: EKReminder, at index: Int?, listName: String? = // Additional, independently-composable filters for `show`/`show-all`, ANDed together and ANDed // with the pre-existing day-granularity `--due-date`/`--include-overdue` filter. `now`, -// `dueBefore`, and `dueAfter` are resolved to concrete `Date`s once per command invocation by -// the caller, not per reminder. Kept internal (not private) so it's directly unit-testable via -// `@testable import`, matching `recurrenceEndDate`/`nextOccurrence` elsewhere in this file. +// `dueBefore`, `dueAfter`, and `completedSince` are resolved to concrete `Date`s once per command +// invocation by the caller, not per reminder. Kept internal (not private) so it's directly +// unit-testable via `@testable import`, matching `recurrenceEndDate`/`nextOccurrence` elsewhere in +// this file. func matchesAdditionalFilters( _ reminder: EKReminder, now: Date, @@ -74,7 +75,8 @@ func matchesAdditionalFilters( dueAfter: Date?, noDueDate: Bool, priorities: [Priority], - search: String? + search: String?, + completedSince: Date? = nil ) -> Bool { let reminderDueDate = reminder.dueDateComponents?.date @@ -90,6 +92,9 @@ func matchesAdditionalFilters( if let dueAfter, !(reminderDueDate.map { $0 >= dueAfter } ?? false) { return false } + if let completedSince, !(reminder.completionDate.map { $0 >= completedSince } ?? false) { + return false + } if !priorities.isEmpty { let reminderPriority = Priority(reminder.mappedPriority) ?? .none if !priorities.contains(reminderPriority) { @@ -479,7 +484,7 @@ public final class Reminders { dueOn dueDate: DateComponents?, includeOverdue: Bool, overdue: Bool = false, dueBefore: DateComponents? = nil, dueAfter: DateComponents? = nil, noDueDate: Bool = false, priorities: [Priority] = [], search: String? = nil, - lists: [String] = [], + lists: [String] = [], completedSince: DateComponents? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder ) { let semaphore = DispatchSemaphore(value: 0) @@ -490,6 +495,7 @@ public final class Reminders { // lower bound. let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date + let completedSinceDate = completedSince?.date // Resolving --list up front means an unknown list name or ID hard-errors via // calendar(withNameOrId:)'s existing exit(1) before any reminders are fetched. let calendars = lists.isEmpty ? self.getCalendars() : lists.map { self.calendar(withNameOrId: $0) } @@ -520,7 +526,8 @@ public final class Reminders { guard matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBeforeDate, - dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search + dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search, + completedSince: completedSinceDate ) else { continue } @@ -547,6 +554,7 @@ public final class Reminders { withNameOrId nameOrId: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, overdue: Bool = false, dueBefore: DateComponents? = nil, dueAfter: DateComponents? = nil, noDueDate: Bool = false, priorities: [Priority] = [], search: String? = nil, + completedSince: DateComponents? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder) { let reminderCalendar = self.calendar(withNameOrId: nameOrId) @@ -555,6 +563,7 @@ public final class Reminders { let now = Date() let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date + let completedSinceDate = completedSince?.date self.reminders(on: [reminderCalendar], displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int?)]() @@ -581,7 +590,8 @@ public final class Reminders { guard matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBeforeDate, - dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search + dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search, + completedSince: completedSinceDate ) else { continue } diff --git a/Tests/RemindersTests/FilterTests.swift b/Tests/RemindersTests/FilterTests.swift index 7167fd8..fcb8a21 100644 --- a/Tests/RemindersTests/FilterTests.swift +++ b/Tests/RemindersTests/FilterTests.swift @@ -12,7 +12,8 @@ final class FilterTests: XCTestCase { } private func makeReminder( - title: String = "Test", notes: String? = nil, due: Date? = nil, priority: Priority = .none + title: String = "Test", notes: String? = nil, due: Date? = nil, priority: Priority = .none, + completed: Date? = nil ) -> EKReminder { let reminder = EKReminder(eventStore: store) let calendar = EKCalendar(for: .reminder, eventStore: store) @@ -25,6 +26,10 @@ final class FilterTests: XCTestCase { reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: due) } + if let completed { + reminder.isCompleted = true + reminder.completionDate = completed + } return reminder } @@ -36,11 +41,12 @@ final class FilterTests: XCTestCase { dueAfter: Date? = nil, noDueDate: Bool = false, priorities: [Priority] = [], - search: String? = nil + search: String? = nil, + completedSince: Date? = nil ) -> Bool { matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBefore, dueAfter: dueAfter, - noDueDate: noDueDate, priorities: priorities, search: search) + noDueDate: noDueDate, priorities: priorities, search: search, completedSince: completedSince) } func testNoFiltersMatchesEverything() throws { @@ -95,6 +101,23 @@ final class FilterTests: XCTestCase { XCTAssertFalse(matches(makeReminder(), dueAfter: cutoff)) } + func testCompletedSinceIncludesExactBoundary() throws { + let cutoff = wholeSecond() + let reminder = makeReminder(completed: cutoff) + XCTAssertTrue(matches(reminder, completedSince: cutoff)) + } + + func testCompletedSinceRejectsBeforeBoundary() throws { + let cutoff = wholeSecond() + let reminder = makeReminder(completed: cutoff.addingTimeInterval(-1)) + XCTAssertFalse(matches(reminder, completedSince: cutoff)) + } + + func testCompletedSinceRejectsIncompleteReminder() throws { + let cutoff = wholeSecond() + XCTAssertFalse(matches(makeReminder(), completedSince: cutoff)) + } + func testNoDueDateMatchesReminderWithoutDueDate() throws { XCTAssertTrue(matches(makeReminder(), noDueDate: true)) }