Notifications !23

merged merged by cmc on 2026-10-05 06:33 UTC · krz/orgstar:phase3-notifications into main

10 files changed, +416 −27

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +35 −20
@@ -54,6 +54,37 @@ public final class AgendaModel {
5454 startOffset = viewStart ?? startOffset
5555 }
5656 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] {
5788 let defaults = workspace.settings.org
5889 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
5990 for path in files {
@@ -67,9 +98,7 @@ public final class AgendaModel {
6798 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
6899 reads.append((path, cached, mtime, size, nil))
69100 }
70 let options = self.options
71 let mode = self.mode
72 let result = await Task.detached {
101 let loaded = await Task.detached {
73102 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
74103 for read in reads {
75104 if let cached = read.cached {
@@ -81,26 +110,12 @@ public final class AgendaModel {
81110 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
82111 }
83112 }
84 let all = sources.map(\.source)
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)
113 return sources
96114 }.value
97 guard id == generation else { return }
98 for source in result.0 where source.fromDisk {
115 for source in loaded where source.fromDisk {
99116 cache[source.path] = (source.mtime, source.size, source.source)
100117 }
101 days = result.1
102 list = result.2
103 isComputing = false
118 return loaded.map(\.source)
104119 }
105120
106121 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 {
3535 public let todo: String?
3636 public let isDone: Bool
3737 public let urgency: Int
38 /// `APPT_WARNTIME`: minutes of warning before the entry's time.
39 public let warntime: Int?
3840 /// The line as org prints it, with one space before the tags.
3941 public let line: String
4042}
@@ -277,6 +279,7 @@ public enum Agenda {
277279 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category,
278280 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
279281 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
282 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) },
280283 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
281284 )
282285 }
@@ -391,7 +394,7 @@ public enum Agenda {
391394 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
392395 return AgendaItem(
393396 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,
395398 line: " " + pad("", 12) + pad(timed.time, 12) + txt
396399 )
397400 }
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 {
1010 let workspace: WorkspaceModel
1111 let session: DocumentSession
1212 let commands: AppCommands
13 let scheduler: ReminderScheduler?
1314 @State private var agenda: AgendaModel
1415 @State private var views = AgendaViewLoader.builtIn
1516 @State private var viewName = AgendaViewLoader.builtIn[0].name
@@ -28,10 +29,11 @@ struct AgendaView: View {
2829 static let match = "Match…"
2930 static let todoMatch = "TODO Match…"
3031
31 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
32 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) {
3233 self.workspace = workspace
3334 self.session = session
3435 self.commands = commands
36 self.scheduler = scheduler
3537 _agenda = State(initialValue: AgendaModel(workspace: workspace))
3638 }
3739
@@ -98,7 +100,7 @@ struct AgendaView: View {
98100 }
99101 .onKeyPress(phases: .down) { press in handle(press) }
100102 .safeAreaInset(edge: .bottom, spacing: 0) {
101 if let message {
103 if let message = message ?? reminderStatus {
102104 Text(message)
103105 .font(.callout)
104106 .frame(maxWidth: .infinity, alignment: .leading)
@@ -178,6 +180,16 @@ struct AgendaView: View {
178180 }
179181 }
180182
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
181193 private var listTitle: String {
182194 switch agenda.mode {
183195 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
Sources/Orgstar/ContentView.swift +46
@@ -8,6 +8,12 @@ struct ContentView: View {
88 let workspace: WorkspaceModel
99 let session: DocumentSession
1010 @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
1117 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
1218 @AppStorage("keymap") private var preset = KeymapPreset.emacs
1319 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
@@ -137,6 +143,18 @@ struct ContentView: View {
137143 } message: {
138144 Text(workspace.lastError ?? session.lastError ?? "")
139145 }
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 }
140158 .task {
141159 DevelopmentHooks.scheduleSnapshot()
142160 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
@@ -147,6 +165,34 @@ struct ContentView: View {
147165 }
148166 }
149167
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
150196 private func loadKeymap() {
151197 let loaded = KeymapLoader.load(preset)
152198 keymap = loaded.keymap
Sources/Orgstar/OrgstarApp.swift +36 −4
@@ -3,6 +3,7 @@ import OrgApp
33import OrgCore
44import OrgEditorAppKit
55import SwiftUI
6import UserNotifications
67
78/// Window-level requests the menu bar makes.
89@MainActor
@@ -62,8 +63,30 @@ struct PromptRequest: Identifiable {
6263}
6364
6465@MainActor
65final class AppDelegate: NSObject, NSApplicationDelegate {
66final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
6667 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 }
6790
6891 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
6992
@@ -102,6 +125,7 @@ struct OrgstarApp: App {
102125 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
103126 }
104127 @State private var commands = AppCommands()
128 @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
105129 @State private var session: DocumentSession = {
106130 let session = DocumentSession()
107131 session.defaults = Preferences.orgSettings
@@ -112,8 +136,11 @@ struct OrgstarApp: App {
112136 Window("Orgstar", id: "main") {
113137 switch workspace {
114138 case .success(let model):
115 ContentView(workspace: model, session: session, commands: commands)
116 .onAppear { delegate.session = session }
139 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler)
140 .onAppear {
141 delegate.session = session
142 delegate.commands = commands
143 }
117144 case .failure(let error):
118145 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
119146 }
@@ -121,7 +148,7 @@ struct OrgstarApp: App {
121148 .defaultSize(width: 1280, height: 860)
122149 Window("Agenda", id: "agenda") {
123150 if case .success(let model) = workspace {
124 AgendaView(workspace: model, session: session, commands: commands)
151 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
125152 }
126153 }
127154 .defaultSize(width: 760, height: 720)
@@ -188,6 +215,8 @@ struct SettingsView: View {
188215 @AppStorage("agendaSpan") private var agendaSpan = 10
189216 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
190217 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
218 @AppStorage("reminders") private var reminders = true
219 @AppStorage("reminderLead") private var reminderLead = 12
191220
192221 var body: some View {
193222 Form {
@@ -231,6 +260,9 @@ struct SettingsView: View {
231260 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
232261 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
233262 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)
234266 }
235267 .padding(20)
236268 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +47
@@ -373,3 +373,50 @@ struct AgendaCommandTests {
373373 #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n")
374374 }
375375}
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 {
324324 }
325325 }
326326}
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".