diff --git a/README.md b/README.md index 7b6a444..d27f5e4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ remindctl list Projects --create remindctl add "Buy milk" remindctl add --title "Call mom" --list Personal --due tomorrow +remindctl add "Plan trip" --due "2026-03-06" # all-day +remindctl add "Doctor" --due "2026-03-06 15:00" # timed +remindctl add "Holiday" --due "2026-03-06" --all-day # force all-day remindctl edit 1 --title "New title" --due 2026-01-04 remindctl complete 1 2 3 remindctl delete 4A83 --force @@ -68,6 +71,9 @@ Accepted by `--due` and filters: - `YYYY-MM-DD HH:mm` - ISO 8601 (`2026-01-03T12:34:56Z`) +For `add`, date-only values (for example `2026-03-06`) create all-day reminders. +Use `--all-day` to explicitly force all-day behavior when setting `--due`. + ## Permissions Run `remindctl authorize` to trigger the system prompt. If access is denied, enable Terminal (or remindctl) in System Settings → Privacy & Security → Reminders. diff --git a/Sources/RemindCore/DateParsing.swift b/Sources/RemindCore/DateParsing.swift index b3be087..7cbf3d3 100644 --- a/Sources/RemindCore/DateParsing.swift +++ b/Sources/RemindCore/DateParsing.swift @@ -29,13 +29,13 @@ public enum DateParsing { return nil } - public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String { + public static func formatDisplay(_ date: Date, isAllDay: Bool = false, calendar: Calendar = .current) -> String { let formatter = DateFormatter() formatter.locale = Locale.current formatter.timeZone = calendar.timeZone formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: date) + formatter.timeStyle = isAllDay ? .none : .short + return isAllDay ? "\(formatter.string(from: date)) (all day)" : formatter.string(from: date) } private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? { diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..9cff0e5 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -101,7 +101,7 @@ public actor RemindersStore { reminder.calendar = calendar reminder.priority = draft.priority.eventKitValue if let dueDate = draft.dueDate { - reminder.dueDateComponents = calendarComponents(from: dueDate) + reminder.dueDateComponents = calendarComponents(from: dueDate, isAllDay: draft.dueDateIsAllDay) } try eventStore.save(reminder, commit: true) return ReminderItem( @@ -112,6 +112,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + dueDateIsAllDay: isAllDay(reminder.dueDateComponents), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -153,6 +154,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + dueDateIsAllDay: isAllDay(reminder.dueDateComponents), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -173,6 +175,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + dueDateIsAllDay: isAllDay(reminder.dueDateComponents), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -212,6 +215,7 @@ public actor RemindersStore { let completionDate: Date? let priority: Int let dueDateComponents: DateComponents? + let dueDateIsAllDay: Bool let listID: String let listName: String } @@ -220,14 +224,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 allDay = 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, + dueDateIsAllDay: allDay, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -245,6 +252,7 @@ public actor RemindersStore { completionDate: data.completionDate, priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), + dueDateIsAllDay: data.dueDateIsAllDay, listID: data.listID, listName: data.listName ) @@ -266,8 +274,11 @@ 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, isAllDay: Bool = false) -> DateComponents { + if isAllDay { + return calendar.dateComponents([.year, .month, .day], from: date) + } + return calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) } private func date(from components: DateComponents?) -> Date? { @@ -275,6 +286,11 @@ public actor RemindersStore { return calendar.date(from: components) } + private func isAllDay(_ components: DateComponents?) -> Bool { + guard let components else { return false } + return components.hour == nil && components.minute == nil + } + private func item(from reminder: EKReminder) -> ReminderItem { ReminderItem( id: reminder.calendarItemIdentifier, @@ -284,6 +300,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + dueDateIsAllDay: isAllDay(reminder.dueDateComponents), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..06557d5 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 dueDateIsAllDay: 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?, + dueDateIsAllDay: 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.dueDateIsAllDay = dueDateIsAllDay self.listID = listID self.listName = listName } @@ -81,12 +84,20 @@ public struct ReminderDraft: Sendable { public let title: String public let notes: String? public let dueDate: Date? + public let dueDateIsAllDay: Bool public let priority: ReminderPriority - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + dueDateIsAllDay: Bool = false, + priority: ReminderPriority + ) { self.title = title self.notes = notes self.dueDate = dueDate + self.dueDateIsAllDay = dueDateIsAllDay self.priority = priority } } diff --git a/Sources/remindctl/CommandHelpers.swift b/Sources/remindctl/CommandHelpers.swift index 6323cdd..d88e2c3 100644 --- a/Sources/remindctl/CommandHelpers.swift +++ b/Sources/remindctl/CommandHelpers.swift @@ -2,6 +2,11 @@ import Foundation import RemindCore enum CommandHelpers { + struct ParsedDueDate { + let date: Date + let isAllDay: Bool + } + static func parsePriority(_ value: String) throws -> ReminderPriority { switch value.lowercased() { case "none": @@ -23,4 +28,22 @@ enum CommandHelpers { } return date } + + static func parseAddDueDate(_ value: String, forceAllDay: Bool) throws -> ParsedDueDate { + let date = try parseDueDate(value) + let isAllDay = forceAllDay || isDateOnlyInput(value) + return ParsedDueDate(date: date, isAllDay: isAllDay) + } + + private static func isDateOnlyInput(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let patterns = [ + #"^\d{4}-\d{2}-\d{2}$"#, + #"^\d{1,2}/\d{1,2}/\d{4}$"#, + #"^\d{1,2}-\d{1,2}-(\d{2}|\d{4})$"#, + ] + return patterns.contains { pattern in + trimmed.range(of: pattern, options: .regularExpression) != nil + } + } } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 571bc25..71fe5f9 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -24,12 +24,16 @@ enum AddCommand { help: "none|low|medium|high", parsing: .singleValue ), + ], + flags: [ + .make(label: "allDay", names: [.long("all-day")], help: "Treat --due as an all-day reminder"), ] ) ), usageExamples: [ "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", + "remindctl add \"Plan trip\" --due 2026-03-06 --all-day", "remindctl add \"Review docs\" --priority high", ] ) { values, runtime in @@ -56,8 +60,13 @@ enum AddCommand { let notes = values.option("notes") let dueValue = values.option("due") let priorityValue = values.option("priority") + let allDayFlag = values.flag("allDay") + + if allDayFlag && dueValue == nil { + throw RemindCoreError.operationFailed("--all-day requires --due") + } - let dueDate = try dueValue.map(CommandHelpers.parseDueDate) + let dueInput = try dueValue.map { try CommandHelpers.parseAddDueDate($0, forceAllDay: allDayFlag) } let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none let store = RemindersStore() @@ -73,7 +82,13 @@ enum AddCommand { throw RemindCoreError.operationFailed("No default list found. Specify --list.") } - let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority) + let draft = ReminderDraft( + title: title, + notes: notes, + dueDate: dueInput?.date, + dueDateIsAllDay: dueInput?.isAllDay ?? false, + priority: priority + ) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) } diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..3b57a46 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -50,7 +50,7 @@ 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, isAllDay: reminder.dueDateIsAllDay) } ?? "no due date" Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") case .plain: Swift.print(plainLine(for: reminder)) @@ -96,7 +96,7 @@ 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, isAllDay: reminder.dueDateIsAllDay) } ?? "no due date" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") } diff --git a/Tests/remindctlTests/AddCommandParsingTests.swift b/Tests/remindctlTests/AddCommandParsingTests.swift new file mode 100644 index 0000000..618d64a --- /dev/null +++ b/Tests/remindctlTests/AddCommandParsingTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing + +@testable import remindctl + +@MainActor +struct AddCommandParsingTests { + @Test("Date-only due input defaults to all-day") + func dateOnlyDueDefaultsToAllDay() throws { + let parsed = try CommandHelpers.parseAddDueDate("2026-03-06", forceAllDay: false) + #expect(parsed.isAllDay == true) + } + + @Test("Date-time due input remains timed") + func dateTimeDueRemainsTimed() throws { + let parsed = try CommandHelpers.parseAddDueDate("2026-03-06 15:00", forceAllDay: false) + #expect(parsed.isAllDay == false) + } + + @Test("--all-day forces all-day for due input") + func allDayFlagForcesAllDay() throws { + let parsed = try CommandHelpers.parseAddDueDate("2026-03-06 15:00", forceAllDay: true) + #expect(parsed.isAllDay == true) + } +}