From 1172a3aadd4f1d406ac84e3eecc5da5985ce664c Mon Sep 17 00:00:00 2001 From: Mark Ayers Date: Tue, 17 Feb 2026 14:21:23 -0500 Subject: [PATCH 1/2] fix: use existing item(from:) helper to resolve type_body_length lint warning Replace 3 inline ReminderItem constructions in createReminder, updateReminder, and completeReminders with calls to the existing item(from:) helper. No behavior change. Closes #28 Co-Authored-By: Claude Opus 4.6 --- Sources/RemindCore/EventKitStore.swift | 38 ++------------------------ 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..74b06d6 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -104,17 +104,7 @@ public actor RemindersStore { reminder.dueDateComponents = calendarComponents(from: dueDate) } try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { @@ -145,17 +135,7 @@ public actor RemindersStore { try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func completeReminders(ids: [String]) async throws -> [ReminderItem] { @@ -164,19 +144,7 @@ public actor RemindersStore { let reminder = try reminder(withID: id) reminder.isCompleted = true try eventStore.save(reminder, commit: true) - updated.append( - ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) - ) + updated.append(item(from: reminder)) } return updated } From b2a1d639e9eed1f89d9a505766aa5994242cfb6d Mon Sep 17 00:00:00 2001 From: Mark Ayers Date: Tue, 17 Feb 2026 15:02:50 -0500 Subject: [PATCH 2/2] feat: support all-day (date-only) reminders Date-only inputs (today, tomorrow, yyyy-MM-dd, etc.) now produce true all-day reminders by omitting hour/minute from dueDateComponents. Date+time inputs and ISO 8601 continue to produce timed reminders. Adds ParsedDate type to thread isDateOnly through parsing, models, EventKit storage, and display formatting. Closes #6, closes #23 Co-Authored-By: Claude Opus 4.6 --- Sources/RemindCore/DateParsing.swift | 56 +++++++++++++------- Sources/RemindCore/EventKitStore.swift | 32 +++++++---- Sources/RemindCore/Models.swift | 11 ++-- Sources/RemindCore/ReminderFilter.swift | 4 +- Sources/remindctl/CommandHelpers.swift | 6 +-- Sources/remindctl/Commands/EditCommand.swift | 2 +- Sources/remindctl/OutputFormatting.swift | 29 ++++++++-- Tests/RemindCoreTests/DateParsingTests.swift | 53 ++++++++++++++++-- 8 files changed, 147 insertions(+), 46 deletions(-) diff --git a/Sources/RemindCore/DateParsing.swift b/Sources/RemindCore/DateParsing.swift index b3be087..80d1dab 100644 --- a/Sources/RemindCore/DateParsing.swift +++ b/Sources/RemindCore/DateParsing.swift @@ -1,11 +1,21 @@ import Foundation +public struct ParsedDate: Sendable, Equatable { + public let date: Date + public let isDateOnly: Bool + + public init(date: Date, isDateOnly: Bool) { + self.date = date + self.isDateOnly = isDateOnly + } +} + public enum DateParsing { public static func parseUserDate( _ input: String, now: Date = Date(), calendar: Calendar = .current - ) -> Date? { + ) -> ParsedDate? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) let lower = trimmed.lowercased() @@ -17,37 +27,43 @@ public enum DateParsing { isoFormatter(withFraction: true).date(from: trimmed) ?? isoFormatter(withFraction: false).date(from: trimmed) if let iso { - return iso + return ParsedDate(date: iso, isDateOnly: false) } - for formatter in dateFormatters() { + for (formatter, dateOnly) in dateFormattersWithContext() { if let date = formatter.date(from: trimmed) { - return date + return ParsedDate(date: date, isDateOnly: dateOnly) } } return nil } - public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String { + public static func formatDisplay( + _ date: Date, isDateOnly: Bool = false, calendar: Calendar = .current + ) -> String { let formatter = DateFormatter() formatter.locale = Locale.current formatter.timeZone = calendar.timeZone formatter.dateStyle = .medium - formatter.timeStyle = .short + formatter.timeStyle = isDateOnly ? .none : .short return formatter.string(from: date) } - private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? { + private static func parseRelativeDate( + _ input: String, now: Date, calendar: Calendar + ) -> ParsedDate? { switch input { case "today": - return calendar.startOfDay(for: now) + return ParsedDate(date: calendar.startOfDay(for: now), isDateOnly: true) case "tomorrow": return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) + .map { ParsedDate(date: $0, isDateOnly: true) } case "yesterday": return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) + .map { ParsedDate(date: $0, isDateOnly: true) } case "now": - return now + return ParsedDate(date: now, isDateOnly: false) default: return nil } @@ -62,22 +78,22 @@ public enum DateParsing { return formatter } - private static func dateFormatters() -> [DateFormatter] { - let formats = [ - "yyyy-MM-dd", - "yyyy-MM-dd HH:mm", - "yyyy-MM-dd HH:mm:ss", - "MM/dd/yyyy", - "MM/dd/yyyy HH:mm", - "dd-MM-yy", - "dd-MM-yyyy", + private static func dateFormattersWithContext() -> [(DateFormatter, Bool)] { + let formats: [(String, Bool)] = [ + ("yyyy-MM-dd", true), + ("yyyy-MM-dd HH:mm", false), + ("yyyy-MM-dd HH:mm:ss", false), + ("MM/dd/yyyy", true), + ("MM/dd/yyyy HH:mm", false), + ("dd-MM-yy", true), + ("dd-MM-yyyy", true), ] - return formats.map { format in + return formats.map { format, dateOnly in let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current formatter.dateFormat = format - return formatter + return (formatter, dateOnly) } } } diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index 74b06d6..e91dab1 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -100,8 +100,8 @@ public actor RemindersStore { reminder.notes = draft.notes reminder.calendar = calendar reminder.priority = draft.priority.eventKitValue - if let dueDate = draft.dueDate { - reminder.dueDateComponents = calendarComponents(from: dueDate) + if let parsed = draft.dueDate { + reminder.dueDateComponents = calendarComponents(from: parsed.date, dateOnly: parsed.isDateOnly) } try eventStore.save(reminder, commit: true) return item(from: reminder) @@ -117,8 +117,8 @@ public actor RemindersStore { reminder.notes = notes } if let dueDateUpdate = update.dueDate { - if let dueDate = dueDateUpdate { - reminder.dueDateComponents = calendarComponents(from: dueDate) + if let parsed = dueDateUpdate { + reminder.dueDateComponents = calendarComponents(from: parsed.date, dateOnly: parsed.isDateOnly) } else { reminder.dueDateComponents = nil } @@ -180,6 +180,7 @@ public actor RemindersStore { let completionDate: Date? let priority: Int let dueDateComponents: DateComponents? + let isDateOnly: Bool let listID: String let listName: String } @@ -188,14 +189,17 @@ public actor RemindersStore { let predicate = eventStore.predicateForReminders(in: calendars) eventStore.fetchReminders(matching: predicate) { reminders in let data = (reminders ?? []).map { reminder in - ReminderData( + let components = reminder.dueDateComponents + let dateOnly = components != nil && components?.hour == nil && components?.minute == nil + return ReminderData( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", notes: reminder.notes, isCompleted: reminder.isCompleted, completionDate: reminder.completionDate, priority: Int(reminder.priority), - dueDateComponents: reminder.dueDateComponents, + dueDateComponents: components, + isDateOnly: dateOnly, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -213,6 +217,7 @@ public actor RemindersStore { completionDate: data.completionDate, priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), + isDateOnly: data.isDateOnly, listID: data.listID, listName: data.listName ) @@ -234,8 +239,12 @@ public actor RemindersStore { return calendar } - private func calendarComponents(from date: Date) -> DateComponents { - calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + private func calendarComponents(from date: Date, dateOnly: Bool) -> DateComponents { + let components: Set = + dateOnly + ? [.year, .month, .day] + : [.year, .month, .day, .hour, .minute] + return calendar.dateComponents(components, from: date) } private func date(from components: DateComponents?) -> Date? { @@ -244,14 +253,17 @@ public actor RemindersStore { } private func item(from reminder: EKReminder) -> ReminderItem { - ReminderItem( + let components = reminder.dueDateComponents + let dateOnly = components != nil && components?.hour == nil && components?.minute == nil + return ReminderItem( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", notes: reminder.notes, isCompleted: reminder.isCompleted, completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), + dueDate: date(from: components), + isDateOnly: dateOnly, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..a93e5b9 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -51,6 +51,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let completionDate: Date? public let priority: ReminderPriority public let dueDate: Date? + public let isDateOnly: Bool public let listID: String public let listName: String @@ -62,6 +63,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { completionDate: Date?, priority: ReminderPriority, dueDate: Date?, + isDateOnly: Bool = false, listID: String, listName: String ) { @@ -72,6 +74,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.completionDate = completionDate self.priority = priority self.dueDate = dueDate + self.isDateOnly = isDateOnly self.listID = listID self.listName = listName } @@ -80,10 +83,10 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public struct ReminderDraft: Sendable { public let title: String public let notes: String? - public let dueDate: Date? + public let dueDate: ParsedDate? public let priority: ReminderPriority - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init(title: String, notes: String?, dueDate: ParsedDate?, priority: ReminderPriority) { self.title = title self.notes = notes self.dueDate = dueDate @@ -94,7 +97,7 @@ public struct ReminderDraft: Sendable { public struct ReminderUpdate: Sendable { public let title: String? public let notes: String? - public let dueDate: Date?? + public let dueDate: ParsedDate?? public let priority: ReminderPriority? public let listName: String? public let isCompleted: Bool? @@ -102,7 +105,7 @@ public struct ReminderUpdate: Sendable { public init( title: String? = nil, notes: String? = nil, - dueDate: Date?? = nil, + dueDate: ParsedDate?? = nil, priority: ReminderPriority? = nil, listName: String? = nil, isCompleted: Bool? = nil diff --git a/Sources/RemindCore/ReminderFilter.swift b/Sources/RemindCore/ReminderFilter.swift index d602373..c9b7f72 100644 --- a/Sources/RemindCore/ReminderFilter.swift +++ b/Sources/RemindCore/ReminderFilter.swift @@ -30,8 +30,8 @@ public enum ReminderFiltering { case "all", "a": return .all default: - if let date = DateParsing.parseUserDate(token, now: now, calendar: calendar) { - return .date(date) + if let parsed = DateParsing.parseUserDate(token, now: now, calendar: calendar) { + return .date(parsed.date) } return nil } diff --git a/Sources/remindctl/CommandHelpers.swift b/Sources/remindctl/CommandHelpers.swift index 6323cdd..fe7eab9 100644 --- a/Sources/remindctl/CommandHelpers.swift +++ b/Sources/remindctl/CommandHelpers.swift @@ -17,10 +17,10 @@ enum CommandHelpers { } } - static func parseDueDate(_ value: String) throws -> Date { - guard let date = DateParsing.parseUserDate(value) else { + static func parseDueDate(_ value: String) throws -> ParsedDate { + guard let parsed = DateParsing.parseUserDate(value) else { throw RemindCoreError.invalidDate(value) } - return date + return parsed } } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 75a3c31..9f8471f 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -55,7 +55,7 @@ enum EditCommand { let listName = values.option("list") let notes = values.option("notes") - var dueUpdate: Date?? + var dueUpdate: ParsedDate?? if let dueValue = values.option("due") { dueUpdate = try CommandHelpers.parseDueDate(dueValue) } diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..b0ef0a6 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -50,7 +50,10 @@ enum OutputRenderer { static func printReminder(_ reminder: ReminderItem, format: OutputFormat) { switch format { case .standard: - let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" + let due = + reminder.dueDate.map { + DateParsing.formatDisplay($0, isDateOnly: reminder.isDateOnly) + } ?? "no due date" Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") case .plain: Swift.print(plainLine(for: reminder)) @@ -96,7 +99,10 @@ enum OutputRenderer { } for (index, reminder) in sorted.enumerated() { let status = reminder.isCompleted ? "x" : " " - let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" + let due = + reminder.dueDate.map { + DateParsing.formatDisplay($0, isDateOnly: reminder.isDateOnly) + } ?? "no due date" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") } @@ -110,7 +116,16 @@ enum OutputRenderer { } private static func plainLine(for reminder: ReminderItem) -> String { - let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? "" + let due: String + if let dueDate = reminder.dueDate { + if reminder.isDateOnly { + due = dateOnlyFormatter().string(from: dueDate) + } else { + due = isoFormatter().string(from: dueDate) + } + } else { + due = "" + } return [ reminder.id, reminder.listName, @@ -157,4 +172,12 @@ enum OutputRenderer { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter } + + private static func dateOnlyFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } } diff --git a/Tests/RemindCoreTests/DateParsingTests.swift b/Tests/RemindCoreTests/DateParsingTests.swift index 984c8d8..93847a9 100644 --- a/Tests/RemindCoreTests/DateParsingTests.swift +++ b/Tests/RemindCoreTests/DateParsingTests.swift @@ -18,9 +18,9 @@ struct DateParsingTests { let tomorrow = DateParsing.parseUserDate("tomorrow", now: now, calendar: calendar) let yesterday = DateParsing.parseUserDate("yesterday", now: now, calendar: calendar) - #expect(today == calendar.startOfDay(for: now)) - #expect(tomorrow == calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))) - #expect(yesterday == calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))) + #expect(today?.date == calendar.startOfDay(for: now)) + #expect(tomorrow?.date == calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))) + #expect(yesterday?.date == calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))) } @Test("ISO 8601 parsing") @@ -43,4 +43,51 @@ struct DateParsingTests { let output = DateParsing.formatDisplay(date, calendar: calendar) #expect(output.isEmpty == false) } + + @Test("Date-only inputs return isDateOnly true") + func dateOnlyInputs() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let today = DateParsing.parseUserDate("today", now: now, calendar: calendar) + #expect(today?.isDateOnly == true) + + let tomorrow = DateParsing.parseUserDate("tomorrow", now: now, calendar: calendar) + #expect(tomorrow?.isDateOnly == true) + + let yesterday = DateParsing.parseUserDate("yesterday", now: now, calendar: calendar) + #expect(yesterday?.isDateOnly == true) + + let dateOnly = DateParsing.parseUserDate("2026-01-03") + #expect(dateOnly?.isDateOnly == true) + + let slashDate = DateParsing.parseUserDate("01/03/2026") + #expect(slashDate?.isDateOnly == true) + } + + @Test("Date+time inputs return isDateOnly false") + func dateTimeInputs() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let nowResult = DateParsing.parseUserDate("now", now: now, calendar: calendar) + #expect(nowResult?.isDateOnly == false) + + let iso = DateParsing.parseUserDate("2026-01-03T12:34:56Z") + #expect(iso?.isDateOnly == false) + + let dateTime = DateParsing.parseUserDate("2026-01-03 10:30") + #expect(dateTime?.isDateOnly == false) + + let dateTimeSec = DateParsing.parseUserDate("2026-01-03 10:30:00") + #expect(dateTimeSec?.isDateOnly == false) + } + + @Test("formatDisplay omits time when isDateOnly is true") + func displayFormattingDateOnly() { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let withTime = DateParsing.formatDisplay(date, calendar: calendar) + let withoutTime = DateParsing.formatDisplay(date, isDateOnly: true, calendar: calendar) + + #expect(withTime.count > withoutTime.count) + #expect(withoutTime.isEmpty == false) + } }