Sources/OrgPresentation/AgendaRows.swift
342 lines · 14701 bytes
1import Foundation
2import OrgCore
3
4/// An event from the system Calendar, read-only (OrgApp's `CalendarEvents` reads them).
5public struct AgendaEvent: Sendable, Equatable, Identifiable {
6 public let id: String
7 public let title: String
8 public let calendarID: String
9 public let calendarTitle: String
10 /// The calendar's color; nil uses the theme's `agenda-event`.
11 public let color: RGBA?
12 public let start: Date
13 public let end: Date
14 public let isAllDay: Bool
15 public let location: String?
16
17 public init(
18 id: String, title: String, calendarID: String, calendarTitle: String, color: RGBA?, start: Date, end: Date,
19 isAllDay: Bool, location: String? = nil
20 ) {
21 self.id = id
22 self.title = title
23 self.calendarID = calendarID
24 self.calendarTitle = calendarTitle
25 self.color = color
26 self.start = start
27 self.end = end
28 self.isAllDay = isAllDay
29 self.location = location
30 }
31
32 /// The days (as `Days` numbers them) the event covers; an end at midnight ends the day before.
33 public func days(calendar: Calendar) -> ClosedRange<Int> {
34 let first = Days.absolute(start, calendar: calendar)
35 var last = Days.absolute(end, calendar: calendar)
36 if end > start, calendar.startOfDay(for: end) == end { last -= 1 }
37 return first...max(first, last)
38 }
39}
40
41/// One line of the agenda: an Org entry or a calendar event.
42///
43/// The gutter holds the time (`08:00`, `13:50–14:30`) or, untimed, a short status:
44/// deadlines `Due today`, `Due in 2d`, `3d late`, `Due` (on a day other than today);
45/// scheduled `Scheduled`, `4d ago`; habits `Habit`; timestamps, diary sexps and
46/// calendar events `All day`; ranges `Day 2/5`; log mode `Closed`, `Clocked`, `State`; TODO
47/// and match lists nothing. Ages use `AgendaAge.compact`.
48public struct AgendaRow: Sendable, Equatable, Identifiable {
49 /// What the gutter's color says.
50 public enum Status: String, Sendable {
51 /// Not done, with a deadline or scheduled date before today.
52 case overdue
53 /// Not done, with a deadline today or within its warning period.
54 case dueSoon = "due-soon"
55 case normal
56 /// A done TODO keyword.
57 case done
58 }
59
60 public enum Icon: String, Sendable {
61 case scheduled, deadline, habit, event, timestamp
62 }
63
64 /// Unique within a day group.
65 public internal(set) var id: String
66 public let gutter: String
67 public let status: Status
68 /// Nil for TODO and match list entries.
69 public let icon: Icon?
70 /// The category line above the title: the entry's category, or the event's calendar.
71 public let category: String
72 public let title: String
73 public let tags: [String]
74 public let todo: String?
75 public let isDone: Bool
76 public let priority: String?
77 /// The statistics cookie: `1/3`, `50%`.
78 public let progress: String?
79 /// Log mode: the time clocked (`1:30`) or the state set (`DONE`).
80 public let detail: String?
81 /// `HHMM` of the start and the end, when timed.
82 public let start: Int?
83 public let end: Int?
84 /// The deadline or scheduled day, as `Days` numbers it.
85 public let planningDay: Int?
86 /// The Org entry, for its actions.
87 public let item: AgendaItem?
88 public let event: AgendaEvent?
89
90 public var isEvent: Bool { event != nil }
91 /// The calendar's color for an event.
92 public var color: RGBA? { event?.color }
93
94 /// Nil for the time grid and the current-time line.
95 public init?(item: AgendaItem, day: Int, today: Int) {
96 if item.kind == .timeGrid || item.kind == .currentTime { return nil }
97 let timed = item.timeOfDay.map { start in (start, Self.endTime(item.time)) }
98 let planning = item.planningDay
99 let late = planning.map { $0 < today } ?? false
100 var icon: Icon?
101 var status: Status = .normal
102 var untimed = ""
103 switch item.kind {
104 case .deadline, .upcomingDeadline:
105 icon = .deadline
106 let due = planning ?? day
107 if day == today {
108 untimed = due == today ? "Due today" : due < today ? "\(AgendaAge.compact(today - due)) late" : "Due in \(AgendaAge.compact(due - today))"
109 } else {
110 untimed = "Due"
111 }
112 status = late ? .overdue : day == today ? .dueSoon : .normal
113 case .scheduled, .pastScheduled:
114 if item.habit != nil {
115 icon = .habit
116 untimed = "Habit"
117 } else {
118 icon = .scheduled
119 let on = planning ?? day
120 untimed = on < day ? "\(AgendaAge.compact(day - on)) ago" : "Scheduled"
121 }
122 status = late ? .overdue : .normal
123 case .timestamp, .sexp:
124 icon = .timestamp
125 untimed = "All day"
126 case .block:
127 icon = .timestamp
128 let ns = item.extra as NSString
129 if let m = Self.rangeDay.firstMatch(in: item.extra, range: NSRange(location: 0, length: ns.length)) {
130 untimed = "Day " + ns.substring(with: m.range(at: 1))
131 } else {
132 untimed = "All day"
133 }
134 case .closed: (icon, untimed) = (.timestamp, "Closed")
135 case .clock: (icon, untimed) = (.timestamp, "Clocked")
136 case .state: (icon, untimed) = (.timestamp, "State")
137 case .todo, .tagsMatch, .timeGrid, .currentTime: break
138 }
139 if item.isDone { status = .done }
140 self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}")
141 self.gutter = timed.map { Self.timeText($0.0, $0.1) } ?? untimed
142 self.status = status
143 self.icon = icon
144 self.category = item.category
145 self.title = item.title
146 self.tags = item.tags
147 self.todo = item.todo
148 self.isDone = item.isDone
149 self.priority = item.priorityLetter
150 self.progress = item.progress
151 self.detail = item.kind == .clock || item.kind == .state ? Self.logDetail(item.extra) : nil
152 self.start = timed?.0
153 self.end = timed?.1
154 self.planningDay = planning
155 self.item = item
156 self.event = nil
157 }
158
159 /// The event's row on `day`: its times on the day it starts (the end only when it ends that
160 /// day too), `Until 10:00` on the day a longer event ends, `All day` otherwise.
161 public init(event: AgendaEvent, day: Int, calendar: Calendar) {
162 let days = event.days(calendar: calendar)
163 func hhmm(_ date: Date) -> Int {
164 let c = calendar.dateComponents([.hour, .minute], from: date)
165 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
166 }
167 var start: Int?
168 var end: Int?
169 let gutter: String
170 if event.isAllDay || (day != days.lowerBound && day != days.upperBound) {
171 gutter = "All day"
172 } else if day == days.lowerBound {
173 start = hhmm(event.start)
174 if days.lowerBound == days.upperBound { end = hhmm(event.end) }
175 gutter = Self.timeText(start!, end)
176 } else {
177 gutter = "Until " + Self.timeText(hhmm(event.end), nil)
178 }
179 self.id = "event\u{1f}" + event.id
180 self.gutter = gutter
181 self.status = .normal
182 self.icon = .event
183 self.category = event.calendarTitle
184 self.title = event.title
185 self.tags = []
186 self.todo = nil
187 self.isDone = false
188 self.priority = nil
189 self.progress = nil
190 self.detail = nil
191 self.start = start
192 self.end = end
193 self.planningDay = nil
194 self.item = nil
195 self.event = event
196 }
197
198 static let rangeDay = try! NSRegularExpression(pattern: "^\\(([0-9]+/[0-9]+)\\)")
199 static let logParen = try! NSRegularExpression(pattern: "\\(([^)]+)\\)")
200 static let rangeEnd = try! NSRegularExpression(pattern: "-\\+?([0-9]{1,2}):([0-9]{2})")
201
202 /// The text in parentheses of a log row's `extra`: `Clocked: (1:30)`.
203 static func logDetail(_ extra: String) -> String? {
204 let ns = extra as NSString
205 guard let m = logParen.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return nil }
206 let text = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
207 return text.isEmpty ? nil : text
208 }
209
210 /// The end of a `%t` time range (` 9:00-10:30`), as `HHMM`; past midnight (`+1:00`) adds 24 hours.
211 static func endTime(_ time: String) -> Int? {
212 let ns = time as NSString
213 guard let m = rangeEnd.firstMatch(in: time, range: NSRange(location: 0, length: ns.length)) else { return nil }
214 let overtime = ns.substring(with: m.range).contains("+") ? 24 : 0
215 return (Int(ns.substring(with: m.range(at: 1)))! + overtime) * 100 + Int(ns.substring(with: m.range(at: 2)))!
216 }
217
218 /// `08:00`, `13:50–14:30`; hours past midnight wrap.
219 static func timeText(_ start: Int, _ end: Int?) -> String {
220 func text(_ t: Int) -> String { String(format: "%02d:%02d", (t / 100) % 24, t % 100) }
221 return end.map { text(start) + "–" + text($0) } ?? text(start)
222 }
223}
224
225/// One day of the agenda: today's entries carried from earlier days in `overdue`, the rest
226/// in `rows`.
227public struct AgendaDayGroup: Sendable, Equatable, Identifiable {
228 /// As `Days` numbers it.
229 public let day: Int
230 public let isToday: Bool
231 /// Org's day header: `Thursday 8 October 2026`.
232 public let header: String
233 /// Today only: deadlines and scheduled entries from before today, oldest first.
234 public let overdue: [AgendaRow]
235 public let rows: [AgendaRow]
236
237 public var id: Int { day }
238 public var isEmpty: Bool { overdue.isEmpty && rows.isEmpty }
239
240 /// `Today`, `Tomorrow`, `Yesterday`, or nil.
241 public func relativeName(today: Int) -> String? {
242 switch day - today {
243 case 0: "Today"
244 case 1: "Tomorrow"
245 case -1: "Yesterday"
246 default: nil
247 }
248 }
249
250 /// Midnight starting the day.
251 public func date(calendar: Calendar) -> Date {
252 let d = Days.date(day)
253 return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day))!
254 }
255
256 /// Where the current-time line goes in `rows`: before the first timed row starting after
257 /// `now` (`HHMM`), or after the last timed row when all have started. Nil on other days
258 /// and when no row is timed, as Org adds the line only among timed entries.
259 public func nowIndex(now: Int) -> Int? {
260 guard isToday else { return nil }
261 return AgendaRows.nowIndex(rows, now: now)
262 }
263}
264
265public enum AgendaRows {
266 /// The days of `Agenda.list` as rows, with `events` merged in.
267 ///
268 /// - Days with no rows are left out unless `showAllDates` (`org-agenda-show-all-dates`);
269 /// today always stays.
270 /// - Today's past-scheduled entries and past deadlines (not habits) go in `overdue`,
271 /// oldest date first, Org's order among the same date.
272 /// - Other rows keep Org's order (timed by time, then untimed by urgency, habits last).
273 /// All-day events come first, timed events go among the timed entries by start time,
274 /// after entries at the same time.
275 /// - `keep` filters the Org entries (an `AgendaFilter`); events are not filtered.
276 public static func groups(
277 _ days: [AgendaDay], events: [AgendaEvent] = [], today: Int, showAllDates: Bool = false, calendar: Calendar = .current,
278 keep: (AgendaItem) -> Bool = { _ in true }
279 ) -> [AgendaDayGroup] {
280 let eventDays = events.map { ($0, $0.days(calendar: calendar)) }
281 var groups: [AgendaDayGroup] = []
282 for day in days {
283 var overdue: [AgendaRow] = []
284 var rows: [AgendaRow] = []
285 for item in day.items where keep(item) {
286 guard let row = AgendaRow(item: item, day: day.day, today: today) else { continue }
287 if day.day == today, item.habit == nil, let planning = row.planningDay, planning < today {
288 overdue.append(row)
289 } else {
290 rows.append(row)
291 }
292 }
293 overdue = overdue.enumerated().sorted { a, b in
294 a.element.planningDay! != b.element.planningDay! ? a.element.planningDay! < b.element.planningDay! : a.offset < b.offset
295 }.map(\.element)
296 let dayEvents = eventDays.filter { $0.1.contains(day.day) }.map { AgendaRow(event: $0.0, day: day.day, calendar: calendar) }
297 rows = merge(rows, events: dayEvents)
298 let group = AgendaDayGroup(day: day.day, isToday: day.day == today, header: day.header, overdue: unique(overdue), rows: unique(rows))
299 if group.isEmpty, !group.isToday, !showAllDates { continue }
300 groups.append(group)
301 }
302 return groups
303 }
304
305 /// `org` (in Org's order) with event rows added: untimed events first, timed ones before
306 /// the first untimed or habit entry, or the first timed entry starting later.
307 static func merge(_ org: [AgendaRow], events: [AgendaRow]) -> [AgendaRow] {
308 let allDay = events.filter { $0.start == nil }
309 var result = org
310 let timed = events.filter { $0.start != nil }.enumerated().sorted { a, b in
311 a.element.start! != b.element.start! ? a.element.start! < b.element.start! : a.offset < b.offset
312 }.map(\.element)
313 for event in timed {
314 let at = result.firstIndex { row in
315 if row.isEvent { return row.start.map { $0 > event.start! } ?? false }
316 guard let start = row.start, row.item?.habit == nil else { return true }
317 return start > event.start!
318 } ?? result.count
319 result.insert(event, at: at)
320 }
321 return allDay + result
322 }
323
324 /// Ids made unique: a repeat gets `#2`, `#3`.
325 static func unique(_ rows: [AgendaRow]) -> [AgendaRow] {
326 var seen: [String: Int] = [:]
327 return rows.map { row in
328 var row = row
329 let n = seen[row.id, default: 0] + 1
330 seen[row.id] = n
331 if n > 1 { row.id += "#\(n)" }
332 return row
333 }
334 }
335
336 /// See `AgendaDayGroup.nowIndex(now:)`.
337 public static func nowIndex(_ rows: [AgendaRow], now: Int) -> Int? {
338 let timed = rows.indices.filter { rows[$0].start != nil && rows[$0].item?.habit == nil }
339 guard let last = timed.last else { return nil }
340 return timed.first { rows[$0].start! > now } ?? last + 1
341 }
342}