Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions Sources/RemindCore/DateParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
27 changes: 22 additions & 5 deletions Sources/RemindCore/EventKitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
)
Expand Down Expand Up @@ -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
)
Expand All @@ -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
)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
)
Expand All @@ -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
)
Expand All @@ -266,15 +274,23 @@ 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? {
guard let components else { return nil }
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,
Expand All @@ -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
)
Expand Down
13 changes: 12 additions & 1 deletion Sources/RemindCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -62,6 +63,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
completionDate: Date?,
priority: ReminderPriority,
dueDate: Date?,
dueDateIsAllDay: Bool = false,
listID: String,
listName: String
) {
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand Down
23 changes: 23 additions & 0 deletions Sources/remindctl/CommandHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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
}
}
}
19 changes: 17 additions & 2 deletions Sources/remindctl/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/remindctl/OutputFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)")
}
Expand Down
25 changes: 25 additions & 0 deletions Tests/remindctlTests/AddCommandParsingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}