feat: add location reminder triggers

Co-authored-by: Octavio Froid <froid@bohm.com>
This commit is contained in:
Peter Steinberger 2026-05-04 05:58:14 +01:00
parent 20e868d615
commit 85a589366e
No known key found for this signature in database
8 changed files with 163 additions and 4 deletions

View File

@ -1,6 +1,7 @@
# Changelog
## 0.2.0 - 2026-05-04
- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius`
- Add simple recurrence support via `--repeat` and `--no-repeat`
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
- Add reminder `url` to JSON output when EventKit exposes one

View File

@ -16,6 +16,7 @@ let package = Package(
name: "RemindCore",
dependencies: [],
linkerSettings: [
.linkedFramework("CoreLocation"),
.linkedFramework("EventKit"),
]
),

View File

@ -51,6 +51,7 @@ remindctl list Projects --create
remindctl add "Buy milk"
remindctl add --title "Call mom" --list Personal --due tomorrow
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl edit 1 --title "New title" --due 2026-01-04 --clear-alarm
remindctl list Work Errands # show reminders from multiple lists
@ -64,8 +65,8 @@ remindctl authorize # request permissions
- `--json` emits JSON arrays/objects.
- `--plain` emits tab-separated lines.
- `--quiet` emits counts only.
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, and
`recurrenceRule` when available.
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`,
`locationTrigger`, and `recurrenceRule` when available.
File/image attachments are not exposed by EventKit.
## Date formats
@ -80,6 +81,9 @@ Date-only due inputs create all-day reminders; date-time inputs create timed rem
Timed due reminders get a notification alarm at the due time unless `--alarm` sets a different alarm time.
Use `edit <id> --clear-alarm` to remove an alarm.
Use `--location <address>` on `add` to create a location trigger. Add `--leaving` to trigger when leaving
instead of arriving, and `--radius <meters>` to customize the geofence radius.
## Repeat
Use `--repeat` with `add` or `edit` for simple recurrence:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`

View File

@ -1,3 +1,4 @@
import CoreLocation
import EventKit
import Foundation
@ -116,6 +117,9 @@ public actor RemindersStore {
if let recurrenceRule = draft.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceRule)
}
if let locationTrigger = draft.locationTrigger {
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
}
try eventStore.save(reminder, commit: true)
return item(from: reminder)
}
@ -211,6 +215,7 @@ extension RemindersStore {
let dueDateIsAllDay: Bool
let alarmDate: Date?
let recurrenceRule: RecurrenceRule?
let locationTrigger: LocationTrigger?
let listID: String
let listName: String
}
@ -234,6 +239,7 @@ extension RemindersStore {
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
@ -257,6 +263,7 @@ extension RemindersStore {
dueDateIsAllDay: data.dueDateIsAllDay,
alarmDate: data.alarmDate,
recurrenceRule: data.recurrenceRule,
locationTrigger: data.locationTrigger,
listID: data.listID,
listName: data.listName
)
@ -310,6 +317,7 @@ extension RemindersStore {
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
@ -344,6 +352,44 @@ extension RemindersStore {
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
return RecurrenceRule(frequency: frequency, interval: rule.interval)
}
private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
let structuredLocation = EKStructuredLocation(title: trigger.address)
let location: CLLocation
if let latitude = trigger.latitude, let longitude = trigger.longitude {
location = CLLocation(latitude: latitude, longitude: longitude)
} else {
let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address)
guard let geocodedLocation = placemarks.first?.location else {
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
}
location = geocodedLocation
}
structuredLocation.geoLocation = location
structuredLocation.radius = trigger.radius
let alarm = EKAlarm()
alarm.structuredLocation = structuredLocation
alarm.proximity = trigger.proximity == .arriving ? .enter : .leave
return alarm
}
private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
let structuredLocation = alarm.structuredLocation,
let proximity = LocationProximity(eventKitProximity: alarm.proximity)
else { return nil }
let coordinate = structuredLocation.geoLocation?.coordinate
return LocationTrigger(
address: structuredLocation.title ?? "",
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
radius: structuredLocation.radius,
proximity: proximity
)
}
}
extension RecurrenceFrequency {
@ -381,3 +427,16 @@ extension RecurrenceRule {
frequency.eventKitFrequency
}
}
extension LocationProximity {
fileprivate init?(eventKitProximity: EKAlarmProximity) {
switch eventKitProximity {
case .enter:
self = .arriving
case .leave:
self = .leaving
default:
return nil
}
}
}

View File

@ -74,6 +74,33 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
}
}
public enum LocationProximity: String, Codable, CaseIterable, Sendable {
case arriving
case leaving
}
public struct LocationTrigger: Codable, Sendable, Equatable {
public let address: String
public let latitude: Double?
public let longitude: Double?
public let radius: Double
public let proximity: LocationProximity
public init(
address: String,
latitude: Double? = nil,
longitude: Double? = nil,
radius: Double = 100,
proximity: LocationProximity = .arriving
) {
self.address = address
self.latitude = latitude
self.longitude = longitude
self.radius = radius
self.proximity = proximity
}
}
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
@ -88,6 +115,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let dueDateIsAllDay: Bool
public let alarmDate: Date?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let listID: String
public let listName: String
@ -105,6 +133,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
dueDateIsAllDay: Bool = false,
alarmDate: Date? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
listID: String,
listName: String
) {
@ -121,6 +150,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
self.dueDateIsAllDay = dueDateIsAllDay
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.listID = listID
self.listName = listName
}
@ -132,6 +162,7 @@ public struct ReminderDraft: Sendable {
public let dueDate: ParsedUserDate?
public let alarmDate: ParsedUserDate?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let priority: ReminderPriority
public init(
@ -140,6 +171,7 @@ public struct ReminderDraft: Sendable {
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
priority: ReminderPriority
) {
self.title = title
@ -147,6 +179,7 @@ public struct ReminderDraft: Sendable {
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.priority = priority
}
}

View File

@ -18,6 +18,18 @@ enum AddCommand {
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue),
.make(
label: "location",
names: [.long("location")],
help: "Location address for geofence trigger",
parsing: .singleValue
),
.make(
label: "radius",
names: [.long("radius")],
help: "Geofence radius in meters (default: 100)",
parsing: .singleValue
),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
.make(
label: "repeat",
@ -31,6 +43,9 @@ enum AddCommand {
help: "none|low|medium|high",
parsing: .singleValue
),
],
flags: [
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
]
)
),
@ -38,6 +53,7 @@ enum AddCommand {
"remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high",
]
@ -65,11 +81,18 @@ enum AddCommand {
let notes = values.option("notes")
let dueValue = values.option("due")
let alarmValue = values.option("alarm")
let locationValue = values.option("location")
let radiusValue = values.option("radius")
let repeatValue = values.option("repeat")
let priorityValue = values.option("priority")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
let locationTrigger = try makeLocationTrigger(
location: locationValue,
radius: radiusValue,
leaving: values.flag("leaving")
)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
@ -92,10 +115,38 @@ enum AddCommand {
dueDate: dueDate,
alarmDate: alarmDate,
recurrenceRule: recurrenceRule,
locationTrigger: locationTrigger,
priority: priority
)
let reminder = try await store.createReminder(draft, listName: targetList)
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
}
}
private static func makeLocationTrigger(
location: String?,
radius: String?,
leaving: Bool
) throws -> LocationTrigger? {
if location == nil {
if radius != nil || leaving {
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
}
return nil
}
guard let location else { return nil }
let radius = try radius.map(parseRadius) ?? 100
return LocationTrigger(
address: location,
radius: radius,
proximity: leaving ? .leaving : .arriving
)
}
private static func parseRadius(_ value: String) throws -> Double {
guard let radius = Double(value), radius > 0 else {
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
}
return radius
}
}

View File

@ -20,6 +20,13 @@ struct ReminderItemCodingTests {
dueDate: nil,
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
locationTrigger: LocationTrigger(
address: "1 Apple Park Way",
latitude: 37.3349,
longitude: -122.0090,
radius: 100,
proximity: .arriving
),
listID: "list",
listName: "Inbox"
)
@ -33,5 +40,6 @@ struct ReminderItemCodingTests {
#expect(json.contains(#""url":"https:\/\/example.com""#))
#expect(json.contains(#""alarmDate""#))
#expect(json.contains(#""recurrenceRule""#))
#expect(json.contains(#""locationTrigger""#))
}
}

View File

@ -22,12 +22,14 @@ struct HelpPrinterTests {
#expect(joined.contains("authorize"))
}
@Test("Add and edit help include alarm and repeat options")
func alarmAndRepeatHelp() {
@Test("Add and edit help include alarm, location, and repeat options")
func alarmLocationAndRepeatHelp() {
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
#expect(addHelp.contains("--alarm"))
#expect(addHelp.contains("--location"))
#expect(addHelp.contains("--leaving"))
#expect(addHelp.contains("--repeat"))
#expect(editHelp.contains("--alarm"))
#expect(editHelp.contains("--clear-alarm"))