Skip to content

Commit 85a5893

Browse files
feat: add location reminder triggers
Co-authored-by: Octavio Froid <froid@bohm.com>
1 parent 20e868d commit 85a5893

8 files changed

Lines changed: 163 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## 0.2.0 - 2026-05-04
4+
- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius`
45
- Add simple recurrence support via `--repeat` and `--no-repeat`
56
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
67
- Add reminder `url` to JSON output when EventKit exposes one

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ let package = Package(
1616
name: "RemindCore",
1717
dependencies: [],
1818
linkerSettings: [
19+
.linkedFramework("CoreLocation"),
1920
.linkedFramework("EventKit"),
2021
]
2122
),

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ remindctl list Projects --create
5151
remindctl add "Buy milk"
5252
remindctl add --title "Call mom" --list Personal --due tomorrow
5353
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
54+
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
5455
remindctl add "Take vitamins" --due tomorrow --repeat daily
5556
remindctl edit 1 --title "New title" --due 2026-01-04 --clear-alarm
5657
remindctl list Work Errands # show reminders from multiple lists
@@ -64,8 +65,8 @@ remindctl authorize # request permissions
6465
- `--json` emits JSON arrays/objects.
6566
- `--plain` emits tab-separated lines.
6667
- `--quiet` emits counts only.
67-
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, and
68-
`recurrenceRule` when available.
68+
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`,
69+
`locationTrigger`, and `recurrenceRule` when available.
6970
File/image attachments are not exposed by EventKit.
7071

7172
## Date formats
@@ -80,6 +81,9 @@ Date-only due inputs create all-day reminders; date-time inputs create timed rem
8081
Timed due reminders get a notification alarm at the due time unless `--alarm` sets a different alarm time.
8182
Use `edit <id> --clear-alarm` to remove an alarm.
8283

84+
Use `--location <address>` on `add` to create a location trigger. Add `--leaving` to trigger when leaving
85+
instead of arriving, and `--radius <meters>` to customize the geofence radius.
86+
8387
## Repeat
8488
Use `--repeat` with `add` or `edit` for simple recurrence:
8589
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`

Sources/RemindCore/EventKitStore.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreLocation
12
import EventKit
23
import Foundation
34

@@ -116,6 +117,9 @@ public actor RemindersStore {
116117
if let recurrenceRule = draft.recurrenceRule {
117118
replaceRecurrence(on: reminder, with: recurrenceRule)
118119
}
120+
if let locationTrigger = draft.locationTrigger {
121+
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
122+
}
119123
try eventStore.save(reminder, commit: true)
120124
return item(from: reminder)
121125
}
@@ -211,6 +215,7 @@ extension RemindersStore {
211215
let dueDateIsAllDay: Bool
212216
let alarmDate: Date?
213217
let recurrenceRule: RecurrenceRule?
218+
let locationTrigger: LocationTrigger?
214219
let listID: String
215220
let listName: String
216221
}
@@ -234,6 +239,7 @@ extension RemindersStore {
234239
dueDateIsAllDay: isAllDay(components),
235240
alarmDate: Self.alarmDate(from: reminder),
236241
recurrenceRule: Self.recurrenceRule(from: reminder),
242+
locationTrigger: Self.locationTrigger(from: reminder),
237243
listID: reminder.calendar.calendarIdentifier,
238244
listName: reminder.calendar.title
239245
)
@@ -257,6 +263,7 @@ extension RemindersStore {
257263
dueDateIsAllDay: data.dueDateIsAllDay,
258264
alarmDate: data.alarmDate,
259265
recurrenceRule: data.recurrenceRule,
266+
locationTrigger: data.locationTrigger,
260267
listID: data.listID,
261268
listName: data.listName
262269
)
@@ -310,6 +317,7 @@ extension RemindersStore {
310317
dueDateIsAllDay: isAllDay(components),
311318
alarmDate: Self.alarmDate(from: reminder),
312319
recurrenceRule: Self.recurrenceRule(from: reminder),
320+
locationTrigger: Self.locationTrigger(from: reminder),
313321
listID: reminder.calendar.calendarIdentifier,
314322
listName: reminder.calendar.title
315323
)
@@ -344,6 +352,44 @@ extension RemindersStore {
344352
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
345353
return RecurrenceRule(frequency: frequency, interval: rule.interval)
346354
}
355+
356+
private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
357+
let structuredLocation = EKStructuredLocation(title: trigger.address)
358+
let location: CLLocation
359+
if let latitude = trigger.latitude, let longitude = trigger.longitude {
360+
location = CLLocation(latitude: latitude, longitude: longitude)
361+
} else {
362+
let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address)
363+
guard let geocodedLocation = placemarks.first?.location else {
364+
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
365+
}
366+
location = geocodedLocation
367+
}
368+
369+
structuredLocation.geoLocation = location
370+
structuredLocation.radius = trigger.radius
371+
372+
let alarm = EKAlarm()
373+
alarm.structuredLocation = structuredLocation
374+
alarm.proximity = trigger.proximity == .arriving ? .enter : .leave
375+
return alarm
376+
}
377+
378+
private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
379+
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
380+
let structuredLocation = alarm.structuredLocation,
381+
let proximity = LocationProximity(eventKitProximity: alarm.proximity)
382+
else { return nil }
383+
384+
let coordinate = structuredLocation.geoLocation?.coordinate
385+
return LocationTrigger(
386+
address: structuredLocation.title ?? "",
387+
latitude: coordinate?.latitude,
388+
longitude: coordinate?.longitude,
389+
radius: structuredLocation.radius,
390+
proximity: proximity
391+
)
392+
}
347393
}
348394

349395
extension RecurrenceFrequency {
@@ -381,3 +427,16 @@ extension RecurrenceRule {
381427
frequency.eventKitFrequency
382428
}
383429
}
430+
431+
extension LocationProximity {
432+
fileprivate init?(eventKitProximity: EKAlarmProximity) {
433+
switch eventKitProximity {
434+
case .enter:
435+
self = .arriving
436+
case .leave:
437+
self = .leaving
438+
default:
439+
return nil
440+
}
441+
}
442+
}

Sources/RemindCore/Models.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,33 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
7474
}
7575
}
7676

77+
public enum LocationProximity: String, Codable, CaseIterable, Sendable {
78+
case arriving
79+
case leaving
80+
}
81+
82+
public struct LocationTrigger: Codable, Sendable, Equatable {
83+
public let address: String
84+
public let latitude: Double?
85+
public let longitude: Double?
86+
public let radius: Double
87+
public let proximity: LocationProximity
88+
89+
public init(
90+
address: String,
91+
latitude: Double? = nil,
92+
longitude: Double? = nil,
93+
radius: Double = 100,
94+
proximity: LocationProximity = .arriving
95+
) {
96+
self.address = address
97+
self.latitude = latitude
98+
self.longitude = longitude
99+
self.radius = radius
100+
self.proximity = proximity
101+
}
102+
}
103+
77104
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
78105
public let id: String
79106
public let title: String
@@ -88,6 +115,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
88115
public let dueDateIsAllDay: Bool
89116
public let alarmDate: Date?
90117
public let recurrenceRule: RecurrenceRule?
118+
public let locationTrigger: LocationTrigger?
91119
public let listID: String
92120
public let listName: String
93121

@@ -105,6 +133,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
105133
dueDateIsAllDay: Bool = false,
106134
alarmDate: Date? = nil,
107135
recurrenceRule: RecurrenceRule? = nil,
136+
locationTrigger: LocationTrigger? = nil,
108137
listID: String,
109138
listName: String
110139
) {
@@ -121,6 +150,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
121150
self.dueDateIsAllDay = dueDateIsAllDay
122151
self.alarmDate = alarmDate
123152
self.recurrenceRule = recurrenceRule
153+
self.locationTrigger = locationTrigger
124154
self.listID = listID
125155
self.listName = listName
126156
}
@@ -132,6 +162,7 @@ public struct ReminderDraft: Sendable {
132162
public let dueDate: ParsedUserDate?
133163
public let alarmDate: ParsedUserDate?
134164
public let recurrenceRule: RecurrenceRule?
165+
public let locationTrigger: LocationTrigger?
135166
public let priority: ReminderPriority
136167

137168
public init(
@@ -140,13 +171,15 @@ public struct ReminderDraft: Sendable {
140171
dueDate: ParsedUserDate?,
141172
alarmDate: ParsedUserDate? = nil,
142173
recurrenceRule: RecurrenceRule? = nil,
174+
locationTrigger: LocationTrigger? = nil,
143175
priority: ReminderPriority
144176
) {
145177
self.title = title
146178
self.notes = notes
147179
self.dueDate = dueDate
148180
self.alarmDate = alarmDate
149181
self.recurrenceRule = recurrenceRule
182+
self.locationTrigger = locationTrigger
150183
self.priority = priority
151184
}
152185
}

Sources/remindctl/Commands/AddCommand.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ enum AddCommand {
1818
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
1919
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
2020
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue),
21+
.make(
22+
label: "location",
23+
names: [.long("location")],
24+
help: "Location address for geofence trigger",
25+
parsing: .singleValue
26+
),
27+
.make(
28+
label: "radius",
29+
names: [.long("radius")],
30+
help: "Geofence radius in meters (default: 100)",
31+
parsing: .singleValue
32+
),
2133
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
2234
.make(
2335
label: "repeat",
@@ -31,13 +43,17 @@ enum AddCommand {
3143
help: "none|low|medium|high",
3244
parsing: .singleValue
3345
),
46+
],
47+
flags: [
48+
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
3449
]
3550
)
3651
),
3752
usageExamples: [
3853
"remindctl add \"Buy milk\"",
3954
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
4055
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
56+
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
4157
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
4258
"remindctl add \"Review docs\" --priority high",
4359
]
@@ -65,11 +81,18 @@ enum AddCommand {
6581
let notes = values.option("notes")
6682
let dueValue = values.option("due")
6783
let alarmValue = values.option("alarm")
84+
let locationValue = values.option("location")
85+
let radiusValue = values.option("radius")
6886
let repeatValue = values.option("repeat")
6987
let priorityValue = values.option("priority")
7088

7189
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
7290
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
91+
let locationTrigger = try makeLocationTrigger(
92+
location: locationValue,
93+
radius: radiusValue,
94+
leaving: values.flag("leaving")
95+
)
7396
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
7497
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
7598

@@ -92,10 +115,38 @@ enum AddCommand {
92115
dueDate: dueDate,
93116
alarmDate: alarmDate,
94117
recurrenceRule: recurrenceRule,
118+
locationTrigger: locationTrigger,
95119
priority: priority
96120
)
97121
let reminder = try await store.createReminder(draft, listName: targetList)
98122
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
99123
}
100124
}
125+
126+
private static func makeLocationTrigger(
127+
location: String?,
128+
radius: String?,
129+
leaving: Bool
130+
) throws -> LocationTrigger? {
131+
if location == nil {
132+
if radius != nil || leaving {
133+
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
134+
}
135+
return nil
136+
}
137+
guard let location else { return nil }
138+
let radius = try radius.map(parseRadius) ?? 100
139+
return LocationTrigger(
140+
address: location,
141+
radius: radius,
142+
proximity: leaving ? .leaving : .arriving
143+
)
144+
}
145+
146+
private static func parseRadius(_ value: String) throws -> Double {
147+
guard let radius = Double(value), radius > 0 else {
148+
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
149+
}
150+
return radius
151+
}
101152
}

Tests/RemindCoreTests/ReminderItemCodingTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ struct ReminderItemCodingTests {
2020
dueDate: nil,
2121
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
2222
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
23+
locationTrigger: LocationTrigger(
24+
address: "1 Apple Park Way",
25+
latitude: 37.3349,
26+
longitude: -122.0090,
27+
radius: 100,
28+
proximity: .arriving
29+
),
2330
listID: "list",
2431
listName: "Inbox"
2532
)
@@ -33,5 +40,6 @@ struct ReminderItemCodingTests {
3340
#expect(json.contains(#""url":"https:\/\/example.com""#))
3441
#expect(json.contains(#""alarmDate""#))
3542
#expect(json.contains(#""recurrenceRule""#))
43+
#expect(json.contains(#""locationTrigger""#))
3644
}
3745
}

Tests/remindctlTests/HelpPrinterTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ struct HelpPrinterTests {
2222
#expect(joined.contains("authorize"))
2323
}
2424

25-
@Test("Add and edit help include alarm and repeat options")
26-
func alarmAndRepeatHelp() {
25+
@Test("Add and edit help include alarm, location, and repeat options")
26+
func alarmLocationAndRepeatHelp() {
2727
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
2828
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
2929

3030
#expect(addHelp.contains("--alarm"))
31+
#expect(addHelp.contains("--location"))
32+
#expect(addHelp.contains("--leaving"))
3133
#expect(addHelp.contains("--repeat"))
3234
#expect(editHelp.contains("--alarm"))
3335
#expect(editHelp.contains("--clear-alarm"))

0 commit comments

Comments
 (0)