krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 359666677e

359666677e3403c6c38599c91ff884b7aa3787cd

parent: d5d6891199

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 06:33 UTC

Notifications

Reminders before timed agenda entries, as org-agenda-to-appt gives
appt: 12 minutes before by default, APPT_WARNTIME per entry, seven
days ahead, at most 64. Pending requests are reconciled at launch, on
file changes, after edits and hourly; clicking one shows the entry.
The agenda says how far ahead reminders are set. Settings turn them
off or change the lead.

Ref plan docs/plans/2026-10-05-notifications.md

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +35 −20
@@ -54,6 +54,37 @@ public final class AgendaModel {
54 startOffset = viewStart ?? startOffset 54 startOffset = viewStart ?? startOffset
55 } 55 }
56 let first = start ?? today + startOffset 56 let first = start ?? today + startOffset
57 let options = self.options
58 let mode = self.mode
59 let all = await sources(open: open)
60 let result = await Task.detached {
61 var days: [AgendaDay] = []
62 var list: [AgendaItem] = []
63 switch mode {
64 case .agenda:
65 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
66 case .todo(let keywords):
67 list = Agenda.todoList(all, keywords: keywords)
68 case .match(let match, let todoOnly):
69 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar)
70 }
71 return (days, list)
72 }.value
73 guard id == generation else { return }
74 days = result.0
75 list = result.1
76 isComputing = false
77 }
78
79 /// Reminders for the timed entries of the next `days` days.
80 public func reminders(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current, days: Int = 7, lead: Int = 12) async -> [Reminder] {
81 let all = await sources(open: open)
82 return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value
83 }
84
85 /// The agenda files' sources: the open file's from its text, the others from the cache
86 /// while their modification date and size are unchanged.
87 private func sources(open: (path: String, text: String)?) async -> [AgendaSource] {
57 let defaults = workspace.settings.org 88 let defaults = workspace.settings.org
58 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] 89 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
59 for path in files { 90 for path in files {
@@ -67,9 +98,7 @@ public final class AgendaModel {
67 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } 98 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
68 reads.append((path, cached, mtime, size, nil)) 99 reads.append((path, cached, mtime, size, nil))
69 } 100 }
70 let options = self.options 101 let loaded = await Task.detached {
71 let mode = self.mode
72 let result = await Task.detached {
73 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] 102 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
74 for read in reads { 103 for read in reads {
75 if let cached = read.cached { 104 if let cached = read.cached {
@@ -81,26 +110,12 @@ public final class AgendaModel {
81 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) 110 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
82 } 111 }
83 } 112 }
84 let all = sources.map(\.source) 113 return sources
85 var days: [AgendaDay] = []
86 var list: [AgendaItem] = []
87 switch mode {
88 case .agenda:
89 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
90 case .todo(let keywords):
91 list = Agenda.todoList(all, keywords: keywords)
92 case .match(let match, let todoOnly):
93 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar)
94 }
95 return (sources, days, list)
96 }.value 114 }.value
97 guard id == generation else { return } 115 for source in loaded where source.fromDisk {
98 for source in result.0 where source.fromDisk {
99 cache[source.path] = (source.mtime, source.size, source.source) 116 cache[source.path] = (source.mtime, source.size, source.source)
100 } 117 }
101 days = result.1 118 return loaded.map(\.source)
102 list = result.2
103 isComputing = false
104 } 119 }
105 120
106 public func showToday() { start = nil } 121 public func showToday() { start = nil }
Sources/OrgApp/ReminderScheduler.swift added +95
@@ -0,0 +1,95 @@
1import Foundation
2import Observation
3import OrgCore
4#if canImport(UserNotifications)
5import UserNotifications
6#endif
7
8/// Where reminders are delivered: the system's notification center, or a fake in tests.
9public protocol ReminderCenter: Sendable {
10 /// Whether notifications may be shown, asking the user the first time.
11 func authorize() async -> Bool
12 /// IDs of pending requests.
13 func pendingIDs() async -> [String]
14 func add(_ reminder: Reminder) async throws
15 func remove(_ ids: [String])
16}
17
18/// Keeps the pending notifications equal to the reminders the agenda gives.
19@MainActor
20@Observable
21public final class ReminderScheduler {
22 /// The last event a reminder is set for, so the agenda can say how far ahead they go.
23 public private(set) var scheduledThrough: Date?
24 /// False when the user turned notifications off for the app.
25 public private(set) var isAuthorized = true
26
27 private let center: ReminderCenter
28
29 public init(center: ReminderCenter) {
30 self.center = center
31 }
32
33 /// Adds reminders that are new, removes ours that no longer apply, leaves the rest.
34 public func reconcile(_ reminders: [Reminder]) async {
35 isAuthorized = await center.authorize()
36 guard isAuthorized else {
37 scheduledThrough = nil
38 return
39 }
40 let wanted = Set(reminders.map(\.id))
41 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
42 let stale = pending.subtracting(wanted)
43 if !stale.isEmpty { center.remove(Array(stale)) }
44 for reminder in reminders where !pending.contains(reminder.id) {
45 try? await center.add(reminder)
46 }
47 scheduledThrough = reminders.map(\.eventDate).max()
48 }
49
50 /// Removes every reminder, when the user turns them off.
51 public func clear() async {
52 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
53 if !ours.isEmpty { center.remove(ours) }
54 scheduledThrough = nil
55 }
56}
57
58#if canImport(UserNotifications)
59/// `UNUserNotificationCenter`. Only usable from an app bundle.
60public struct SystemReminderCenter: ReminderCenter {
61 public init() {}
62
63 /// The system center needs a bundle identifier; a bare debug binary has none.
64 public static var isAvailable: Bool { Bundle.main.bundleIdentifier != nil }
65
66 public func authorize() async -> Bool {
67 let center = UNUserNotificationCenter.current()
68 let settings = await center.notificationSettings()
69 switch settings.authorizationStatus {
70 case .authorized, .provisional: return true
71 case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
72 default: return false
73 }
74 }
75
76 public func pendingIDs() async -> [String] {
77 await UNUserNotificationCenter.current().pendingNotificationRequests().map(\.identifier)
78 }
79
80 public func add(_ reminder: Reminder) async throws {
81 let content = UNMutableNotificationContent()
82 content.title = reminder.title
83 content.body = reminder.body
84 content.sound = .default
85 content.userInfo = ["path": reminder.path, "offset": reminder.offset]
86 let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: reminder.fireDate)
87 let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
88 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
89 }
90
91 public func remove(_ ids: [String]) {
92 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
93 }
94}
95#endif
Sources/OrgCore/Agenda/Agenda.swift +4 −1
@@ -35,6 +35,8 @@ public struct AgendaItem: Sendable, Equatable {
35 public let todo: String? 35 public let todo: String?
36 public let isDone: Bool 36 public let isDone: Bool
37 public let urgency: Int 37 public let urgency: Int
38 /// `APPT_WARNTIME`: minutes of warning before the entry's time.
39 public let warntime: Int?
38 /// The line as org prints it, with one space before the tags. 40 /// The line as org prints it, with one space before the tags.
39 public let line: String 41 public let line: String
40} 42}
@@ -277,6 +279,7 @@ public enum Agenda {
277 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, 279 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category,
278 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), 280 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
279 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), 281 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
282 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) },
280 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) 283 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
281 ) 284 )
282 } 285 }
@@ -391,7 +394,7 @@ public enum Agenda {
391 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) 394 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
392 return AgendaItem( 395 return AgendaItem(
393 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, 396 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
394 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, 397 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil,
395 line: " " + pad("", 12) + pad(timed.time, 12) + txt 398 line: " " + pad("", 12) + pad(timed.time, 12) + txt
396 ) 399 )
397 } 400 }
Sources/OrgCore/Agenda/Reminders.swift added +58
@@ -0,0 +1,58 @@
1import Foundation
2
3/// A notification for an agenda entry with a time of day.
4public struct Reminder: Sendable, Equatable {
5 /// Stable while the entry, its day and time stay the same.
6 public let id: String
7 public let fireDate: Date
8 public let eventDate: Date
9 public let title: String
10 public let body: String
11 public let path: String
12 public let offset: Int
13
14 public init(id: String, fireDate: Date, eventDate: Date, title: String, body: String, path: String, offset: Int) {
15 self.id = id
16 self.fireDate = fireDate
17 self.eventDate = eventDate
18 self.title = title
19 self.body = body
20 self.path = path
21 self.offset = offset
22 }
23}
24
25/// What `org-agenda-to-appt` hands appt: timed entries, each with a warning before it.
26public enum Reminders {
27 /// Reminders for the next `days` days from `now`, earliest first, at most `limit`. An
28 /// entry whose warning time has passed but which hasn't started fires now.
29 public static func upcoming(
30 _ sources: [AgendaSource], now: Date, calendar: Calendar, days: Int = 7, lead: Int = 12, limit: Int = 64
31 ) -> [Reminder] {
32 let today = Days.absolute(now, calendar: calendar)
33 let clock = calendar.dateComponents([.hour, .minute], from: now)
34 let list = Agenda.list(sources, start: today, days: days, today: today, now: (clock.hour ?? 0, clock.minute ?? 0))
35 var reminders: [Reminder] = []
36 for day in list {
37 let date = Days.date(day.day)
38 guard let midnight = calendar.date(from: DateComponents(year: date.year, month: date.month, day: date.day)) else { continue }
39 for item in day.items {
40 guard let time = item.timeOfDay, let path = item.path, !item.isDone,
41 [.deadline, .scheduled, .timestamp, .block].contains(item.kind),
42 let event = calendar.date(byAdding: .minute, value: (time / 100) * 60 + time % 100, to: midnight),
43 event > now else { continue }
44 let fire = max(event.addingTimeInterval(-Double((item.warntime ?? lead) * 60)), now.addingTimeInterval(1))
45 let clockText = String(format: "%d:%02d", time / 100, time % 100)
46 let title = item.text.trimmingCharacters(in: .whitespaces)
47 let leader = item.extra.trimmingCharacters(in: .whitespaces)
48 reminders.append(Reminder(
49 id: "orgstar.\(path)|\(item.headingLine ?? title)|\(day.day)|\(time)|\(item.kind.rawValue)",
50 fireDate: fire, eventDate: event, title: title,
51 body: ([clockText] + (leader.isEmpty ? [] : [leader]) + [item.category]).joined(separator: " · "),
52 path: path, offset: item.markerOffset ?? item.headingOffset ?? 0
53 ))
54 }
55 }
56 return Array(reminders.sorted { $0.fireDate < $1.fireDate }.prefix(limit))
57 }
58}
Sources/Orgstar/AgendaView.swift +14 −2
@@ -10,6 +10,7 @@ struct AgendaView: View {
10 let workspace: WorkspaceModel 10 let workspace: WorkspaceModel
11 let session: DocumentSession 11 let session: DocumentSession
12 let commands: AppCommands 12 let commands: AppCommands
13 let scheduler: ReminderScheduler?
13 @State private var agenda: AgendaModel 14 @State private var agenda: AgendaModel
14 @State private var views = AgendaViewLoader.builtIn 15 @State private var views = AgendaViewLoader.builtIn
15 @State private var viewName = AgendaViewLoader.builtIn[0].name 16 @State private var viewName = AgendaViewLoader.builtIn[0].name
@@ -28,10 +29,11 @@ struct AgendaView: View {
28 static let match = "Match…" 29 static let match = "Match…"
29 static let todoMatch = "TODO Match…" 30 static let todoMatch = "TODO Match…"
30 31
31 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { 32 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) {
32 self.workspace = workspace 33 self.workspace = workspace
33 self.session = session 34 self.session = session
34 self.commands = commands 35 self.commands = commands
36 self.scheduler = scheduler
35 _agenda = State(initialValue: AgendaModel(workspace: workspace)) 37 _agenda = State(initialValue: AgendaModel(workspace: workspace))
36 } 38 }
37 39
@@ -98,7 +100,7 @@ struct AgendaView: View {
98 } 100 }
99 .onKeyPress(phases: .down) { press in handle(press) } 101 .onKeyPress(phases: .down) { press in handle(press) }
100 .safeAreaInset(edge: .bottom, spacing: 0) { 102 .safeAreaInset(edge: .bottom, spacing: 0) {
101 if let message { 103 if let message = message ?? reminderStatus {
102 Text(message) 104 Text(message)
103 .font(.callout) 105 .font(.callout)
104 .frame(maxWidth: .infinity, alignment: .leading) 106 .frame(maxWidth: .infinity, alignment: .leading)
@@ -178,6 +180,16 @@ struct AgendaView: View {
178 } 180 }
179 } 181 }
180 182
183 @AppStorage("reminders") private var remindersEnabled = true
184
185 /// How far ahead notifications are set, as the design asks the agenda to show.
186 private var reminderStatus: String? {
187 guard let scheduler, remindersEnabled else { return nil }
188 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
189 guard let through = scheduler.scheduledThrough else { return nil }
190 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
191 }
192
181 private var listTitle: String { 193 private var listTitle: String {
182 switch agenda.mode { 194 switch agenda.mode {
183 case .todo(let keywords): "TODO items: \(keywords ?? "all")" 195 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
Sources/Orgstar/ContentView.swift +46
@@ -8,6 +8,12 @@ struct ContentView: View {
8 let workspace: WorkspaceModel 8 let workspace: WorkspaceModel
9 let session: DocumentSession 9 let session: DocumentSession
10 @Bindable var commands: AppCommands 10 @Bindable var commands: AppCommands
11 let scheduler: ReminderScheduler?
12 @AppStorage("reminders") private var remindersEnabled = true
13 @AppStorage("reminderLead") private var reminderLead = 12
14 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
15 @State private var reminderAgenda: AgendaModel?
16 @State private var hour = 0
11 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 17 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
12 @AppStorage("keymap") private var preset = KeymapPreset.emacs 18 @AppStorage("keymap") private var preset = KeymapPreset.emacs
13 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue 19 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
@@ -137,6 +143,18 @@ struct ContentView: View {
137 } message: { 143 } message: {
138 Text(workspace.lastError ?? session.lastError ?? "") 144 Text(workspace.lastError ?? session.lastError ?? "")
139 } 145 }
146 .task(id: ReminderKey(
147 enabled: remindersEnabled, lead: reminderLead, subfolders: agendaSubfolders, files: workspace.files.count,
148 changed: workspace.changedFile?.version, edits: session.editVersion, hour: hour
149 )) {
150 await updateReminders()
151 }
152 .task {
153 while !Task.isCancelled {
154 try? await Task.sleep(for: .seconds(3600))
155 hour += 1
156 }
157 }
140 .task { 158 .task {
141 DevelopmentHooks.scheduleSnapshot() 159 DevelopmentHooks.scheduleSnapshot()
142 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 160 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
@@ -147,6 +165,34 @@ struct ContentView: View {
147 } 165 }
148 } 166 }
149 167
168 private struct ReminderKey: Hashable {
169 var enabled: Bool
170 var lead: Int
171 var subfolders: Bool
172 var files: Int
173 var changed: Int?
174 var edits: Int
175 var hour: Int
176 }
177
178 /// Sets the notifications for the next week's timed entries, once edits pause.
179 private func updateReminders() async {
180 guard let scheduler else { return }
181 guard remindersEnabled else {
182 await scheduler.clear()
183 return
184 }
185 try? await Task.sleep(for: .seconds(2))
186 guard !Task.isCancelled, !workspace.files.isEmpty else { return }
187 let agenda = reminderAgenda ?? AgendaModel(workspace: workspace)
188 reminderAgenda = agenda
189 agenda.includeSubfolders = agendaSubfolders
190 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
191 let reminders = await agenda.reminders(open: open, lead: reminderLead)
192 guard !Task.isCancelled else { return }
193 await scheduler.reconcile(reminders)
194 }
195
150 private func loadKeymap() { 196 private func loadKeymap() {
151 let loaded = KeymapLoader.load(preset) 197 let loaded = KeymapLoader.load(preset)
152 keymap = loaded.keymap 198 keymap = loaded.keymap
Sources/Orgstar/OrgstarApp.swift +36 −4
@@ -3,6 +3,7 @@ import OrgApp
3import OrgCore 3import OrgCore
4import OrgEditorAppKit 4import OrgEditorAppKit
5import SwiftUI 5import SwiftUI
6import UserNotifications
6 7
7/// Window-level requests the menu bar makes. 8/// Window-level requests the menu bar makes.
8@MainActor 9@MainActor
@@ -62,8 +63,30 @@ struct PromptRequest: Identifiable {
62} 63}
63 64
64@MainActor 65@MainActor
65final class AppDelegate: NSObject, NSApplicationDelegate { 66final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
66 var session: DocumentSession? 67 var session: DocumentSession?
68 var commands: AppCommands?
69
70 func applicationDidFinishLaunching(_ notification: Notification) {
71 if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self }
72 }
73
74 /// Reminders show while Orgstar is in front too.
75 nonisolated func userNotificationCenter(
76 _ center: UNUserNotificationCenter, willPresent notification: UNNotification
77 ) async -> UNNotificationPresentationOptions {
78 [.banner, .sound, .list]
79 }
80
81 /// Clicking a reminder shows its entry.
82 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
83 let info = response.notification.request.content.userInfo
84 guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
85 await MainActor.run {
86 NSApp.activate()
87 commands?.jump(path: path, offset: offset)
88 }
89 }
67 90
68 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } 91 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
69 92
@@ -102,6 +125,7 @@ struct OrgstarApp: App {
102 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) 125 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
103 } 126 }
104 @State private var commands = AppCommands() 127 @State private var commands = AppCommands()
128 @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
105 @State private var session: DocumentSession = { 129 @State private var session: DocumentSession = {
106 let session = DocumentSession() 130 let session = DocumentSession()
107 session.defaults = Preferences.orgSettings 131 session.defaults = Preferences.orgSettings
@@ -112,8 +136,11 @@ struct OrgstarApp: App {
112 Window("Orgstar", id: "main") { 136 Window("Orgstar", id: "main") {
113 switch workspace { 137 switch workspace {
114 case .success(let model): 138 case .success(let model):
115 ContentView(workspace: model, session: session, commands: commands) 139 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler)
116 .onAppear { delegate.session = session } 140 .onAppear {
141 delegate.session = session
142 delegate.commands = commands
143 }
117 case .failure(let error): 144 case .failure(let error):
118 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) 145 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
119 } 146 }
@@ -121,7 +148,7 @@ struct OrgstarApp: App {
121 .defaultSize(width: 1280, height: 860) 148 .defaultSize(width: 1280, height: 860)
122 Window("Agenda", id: "agenda") { 149 Window("Agenda", id: "agenda") {
123 if case .success(let model) = workspace { 150 if case .success(let model) = workspace {
124 AgendaView(workspace: model, session: session, commands: commands) 151 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
125 } 152 }
126 } 153 }
127 .defaultSize(width: 760, height: 720) 154 .defaultSize(width: 760, height: 720)
@@ -188,6 +215,8 @@ struct SettingsView: View {
188 @AppStorage("agendaSpan") private var agendaSpan = 10 215 @AppStorage("agendaSpan") private var agendaSpan = 10
189 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 216 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
190 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 217 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
218 @AppStorage("reminders") private var reminders = true
219 @AppStorage("reminderLead") private var reminderLead = 12
191 220
192 var body: some View { 221 var body: some View {
193 Form { 222 Form {
@@ -231,6 +260,9 @@ struct SettingsView: View {
231 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) 260 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
232 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) 261 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
233 Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders) 262 Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders)
263 Toggle("Notify before timed agenda entries", isOn: $reminders)
264 Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120)
265 .disabled(!reminders)
234 } 266 }
235 .padding(20) 267 .padding(20)
236 .frame(width: 480) 268 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +47
@@ -373,3 +373,50 @@ struct AgendaCommandTests {
373 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n") 373 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
374 } 374 }
375} 375}
376
377final class FakeReminderCenter: ReminderCenter, @unchecked Sendable {
378 private let lock = NSLock()
379 private var pending: [String] = ["someone.else"]
380 var authorized = true
381 private(set) var added: [String] = []
382
383 func authorize() async -> Bool { authorized }
384 func pendingIDs() async -> [String] { lock.withLock { pending } }
385 func add(_ reminder: Reminder) async throws {
386 lock.withLock {
387 pending.append(reminder.id)
388 added.append(reminder.id)
389 }
390 }
391 func remove(_ ids: [String]) { lock.withLock { pending.removeAll { ids.contains($0) } } }
392 var ids: [String] { lock.withLock { pending } }
393}
394
395@MainActor
396struct ReminderSchedulerTests {
397 func reminder(_ id: String, _ minutes: Double) -> Reminder {
398 let date = Date(timeIntervalSince1970: 1_000_000 + minutes * 60)
399 return Reminder(id: "orgstar.\(id)", fireDate: date, eventDate: date.addingTimeInterval(720), title: id, body: "", path: "/a.org", offset: 0)
400 }
401
402 @Test func reconciles() async {
403 let center = FakeReminderCenter()
404 let scheduler = ReminderScheduler(center: center)
405 await scheduler.reconcile([reminder("a", 1), reminder("b", 2)])
406 #expect(center.ids == ["someone.else", "orgstar.a", "orgstar.b"])
407 #expect(scheduler.scheduledThrough == reminder("b", 2).eventDate)
408
409 await scheduler.reconcile([reminder("b", 2), reminder("c", 3)])
410 #expect(center.ids == ["someone.else", "orgstar.b", "orgstar.c"])
411 #expect(center.added == ["orgstar.a", "orgstar.b", "orgstar.c"])
412
413 await scheduler.clear()
414 #expect(center.ids == ["someone.else"])
415 #expect(scheduler.scheduledThrough == nil)
416
417 center.authorized = false
418 await scheduler.reconcile([reminder("d", 4)])
419 #expect(!scheduler.isAuthorized)
420 #expect(center.ids == ["someone.else"])
421 }
422}
Tests/OrgCoreTests/AgendaTests.swift +52
@@ -324,3 +324,55 @@ struct AgendaTests {
324 } 324 }
325 } 325 }
326} 326}
327
328struct ReminderTests {
329 static var utc: Calendar {
330 var calendar = Calendar(identifier: .gregorian)
331 calendar.timeZone = TimeZone(identifier: "UTC")!
332 return calendar
333 }
334
335 static func date(_ day: Int, _ hour: Int, _ minute: Int) -> Date {
336 utc.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!
337 }
338
339 @Test func timedEntriesWithTheirWarnings() {
340 let text = """
341 * TODO Call
342 SCHEDULED: <2026-10-05 Mon 14:00>
343 * Meeting
344 :PROPERTIES:
345 :APPT_WARNTIME: 30
346 :END:
347 <2026-10-06 Tue 10:00-11:00>
348 * DONE Finished
349 SCHEDULED: <2026-10-05 Mon 15:00>
350 * Untimed
351 SCHEDULED: <2026-10-05 Mon>
352 * Earlier
353 <2026-10-05 Mon 08:00>
354 * Soon
355 <2026-10-05 Mon 09:35>
356 * TODO Report due
357 DEADLINE: <2026-10-07 Wed 17:00>
358 """
359 let source = AgendaSource(path: "/n/a.org", text: text)
360 let now = Self.date(5, 9, 30)
361 let reminders = Reminders.upcoming([source], now: now, calendar: Self.utc)
362 #expect(reminders.map(\.title) == ["Soon", "TODO Call", "Meeting", "TODO Report due"])
363 #expect(reminders[0].fireDate == now.addingTimeInterval(1))
364 #expect(reminders[1].fireDate == Self.date(5, 13, 48))
365 #expect(reminders[1].body == "14:00 · Scheduled: · a")
366 #expect(reminders[2].fireDate == Self.date(6, 9, 30))
367 #expect(reminders[3].eventDate == Self.date(7, 17, 0))
368 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, limit: 2).count == 2)
369 #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, days: 1).map(\.title) == ["Soon", "TODO Call"])
370 }
371
372 @Test func repeatsFillTheWeek() {
373 let source = AgendaSource(path: "/n/a.org", text: "* Standup\n<2026-10-01 Thu 09:00 +1d>\n")
374 let reminders = Reminders.upcoming([source], now: Self.date(5, 9, 30), calendar: Self.utc)
375 #expect(reminders.count == 6)
376 #expect(Set(reminders.map(\.id)).count == 6)
377 }
378}
docs/plans/2026-10-05-notifications.md added +29
@@ -0,0 +1,29 @@
1# Notifications Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Local notifications before timed agenda entries, as `org-agenda-to-appt` and appt give in Emacs, kept in step with the files and shown in the agenda.
6
7**Architecture:** `OrgCore/Agenda/Reminders.swift` turns the next days of the agenda into reminders: entries with a time of day (scheduled, deadline, timestamp, range start), not done, fired `APPT_WARNTIME` or the lead time before. `ReminderScheduler` in OrgApp reconciles them with the pending requests of a `NotificationCenter` (the real one wraps `UNUserNotificationCenter`; tests use a fake): removes what no longer applies, adds what's new, keeps at most 64 (the iOS cap). The app reconciles at launch, on file changes and on edits, and hourly. Clicking a notification jumps to the entry. The agenda window says how far ahead reminders are set.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, UserNotifications.
10
11**Spec:** `docs/design.md`, "Phase 3", risks table ("Notifications").
12
13## Global Constraints
14
15- Notifications need the app bundle (`scripts/build-app.sh`); an unbundled debug binary skips them.
16- Request IDs start with `orgstar.`; other requests are left alone.
17
18## Defaults chosen (user may change)
19
20- On, 12 minutes before (appt's `appt-message-warning-time`), 7 days ahead.
21
22---
23
24### Task 1: Reminders
25
26- [x] `Reminders.upcoming`; `AgendaItem.warntime`; tests for leads, `APPT_WARNTIME`, done entries, ranges, the cap, entries already past.
27- [x] `ReminderScheduler` with a fake center: adds, removes, keeps, reports the horizon.
28- [x] App: settings, reconcile triggers, click to jump, agenda footer.
29- [x] Commit "Notifications".