Sources/OrgPresentation/AgendaRows.swift
319 lines · 13731 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`, `Sched. 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 /// `HHMM` of the start and the end, when timed.
80 public let start: Int?
81 public let end: Int?
82 /// The deadline or scheduled day, as `Days` numbers it.
83 public let planningDay: Int?
84 /// The Org entry, for its actions.
85 public let item: AgendaItem?
86 public let event: AgendaEvent?
87
88 public var isEvent: Bool { event != nil }
89 /// The calendar's color for an event.
90 public var color: RGBA? { event?.color }
91
92 /// Nil for the time grid and the current-time line.
93 public init?(item: AgendaItem, day: Int, today: Int) {
94 if item.kind == .timeGrid || item.kind == .currentTime { return nil }
95 let timed = item.timeOfDay.map { start in (start, Self.endTime(item.time)) }
96 let planning = item.planningDay
97 let late = planning.map { $0 < today } ?? false
98 var icon: Icon?
99 var status: Status = .normal
100 var untimed = ""
101 switch item.kind {
102 case .deadline, .upcomingDeadline:
103 icon = .deadline
104 let due = planning ?? day
105 if day == today {
106 untimed = due == today ? "Due today" : due < today ? "\(AgendaAge.compact(today - due)) late" : "Due in \(AgendaAge.compact(due - today))"
107 } else {
108 untimed = "Due"
109 }
110 status = late ? .overdue : day == today ? .dueSoon : .normal
111 case .scheduled, .pastScheduled:
112 if item.habit != nil {
113 icon = .habit
114 untimed = "Habit"
115 } else {
116 icon = .scheduled
117 let on = planning ?? day
118 untimed = on < day ? "Sched. \(AgendaAge.compact(day - on)) ago" : "Scheduled"
119 }
120 status = late ? .overdue : .normal
121 case .timestamp, .sexp:
122 icon = .timestamp
123 untimed = "All day"
124 case .block:
125 icon = .timestamp
126 let ns = item.extra as NSString
127 if let m = Self.rangeDay.firstMatch(in: item.extra, range: NSRange(location: 0, length: ns.length)) {
128 untimed = "Day " + ns.substring(with: m.range(at: 1))
129 } else {
130 untimed = "All day"
131 }
132 case .closed: (icon, untimed) = (.timestamp, "Closed")
133 case .clock: (icon, untimed) = (.timestamp, "Clocked")
134 case .state: (icon, untimed) = (.timestamp, "State")
135 case .todo, .tagsMatch, .timeGrid, .currentTime: break
136 }
137 if item.isDone { status = .done }
138 self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}")
139 self.gutter = timed.map { Self.timeText($0.0, $0.1) } ?? untimed
140 self.status = status
141 self.icon = icon
142 self.category = item.category
143 self.title = item.title
144 self.tags = item.tags
145 self.todo = item.todo
146 self.isDone = item.isDone
147 self.priority = item.priorityLetter
148 self.progress = item.progress
149 self.start = timed?.0
150 self.end = timed?.1
151 self.planningDay = planning
152 self.item = item
153 self.event = nil
154 }
155
156 /// The event's row on `day`: its times on the day it starts (the end only when it ends that
157 /// day too), `Until 10:00` on the day a longer event ends, `All day` otherwise.
158 public init(event: AgendaEvent, day: Int, calendar: Calendar) {
159 let days = event.days(calendar: calendar)
160 func hhmm(_ date: Date) -> Int {
161 let c = calendar.dateComponents([.hour, .minute], from: date)
162 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
163 }
164 var start: Int?
165 var end: Int?
166 let gutter: String
167 if event.isAllDay || (day != days.lowerBound && day != days.upperBound) {
168 gutter = "All day"
169 } else if day == days.lowerBound {
170 start = hhmm(event.start)
171 if days.lowerBound == days.upperBound { end = hhmm(event.end) }
172 gutter = Self.timeText(start!, end)
173 } else {
174 gutter = "Until " + Self.timeText(hhmm(event.end), nil)
175 }
176 self.id = "event\u{1f}" + event.id
177 self.gutter = gutter
178 self.status = .normal
179 self.icon = .event
180 self.category = event.calendarTitle
181 self.title = event.title
182 self.tags = []
183 self.todo = nil
184 self.isDone = false
185 self.priority = nil
186 self.progress = nil
187 self.start = start
188 self.end = end
189 self.planningDay = nil
190 self.item = nil
191 self.event = event
192 }
193
194 static let rangeDay = try! NSRegularExpression(pattern: "^\\(([0-9]+/[0-9]+)\\)")
195 static let rangeEnd = try! NSRegularExpression(pattern: "-\\+?([0-9]{1,2}):([0-9]{2})")
196
197 /// The end of a `%t` time range (` 9:00-10:30`), as `HHMM`; past midnight (`+1:00`) adds 24 hours.
198 static func endTime(_ time: String) -> Int? {
199 let ns = time as NSString
200 guard let m = rangeEnd.firstMatch(in: time, range: NSRange(location: 0, length: ns.length)) else { return nil }
201 let overtime = ns.substring(with: m.range).contains("+") ? 24 : 0
202 return (Int(ns.substring(with: m.range(at: 1)))! + overtime) * 100 + Int(ns.substring(with: m.range(at: 2)))!
203 }
204
205 /// `08:00`, `13:50–14:30`; hours past midnight wrap.
206 static func timeText(_ start: Int, _ end: Int?) -> String {
207 func text(_ t: Int) -> String { String(format: "%02d:%02d", (t / 100) % 24, t % 100) }
208 return end.map { text(start) + "–" + text($0) } ?? text(start)
209 }
210}
211
212/// One day of the agenda: today's entries carried from earlier days in `overdue`, the rest
213/// in `rows`.
214public struct AgendaDayGroup: Sendable, Equatable, Identifiable {
215 /// As `Days` numbers it.
216 public let day: Int
217 public let isToday: Bool
218 /// Org's day header: `Thursday 8 October 2026`.
219 public let header: String
220 /// Today only: deadlines and scheduled entries from before today, oldest first.
221 public let overdue: [AgendaRow]
222 public let rows: [AgendaRow]
223
224 public var id: Int { day }
225 public var isEmpty: Bool { overdue.isEmpty && rows.isEmpty }
226
227 /// Midnight starting the day.
228 public func date(calendar: Calendar) -> Date {
229 let d = Days.date(day)
230 return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day))!
231 }
232
233 /// Where the current-time line goes in `rows`: before the first timed row starting after
234 /// `now` (`HHMM`), or after the last timed row when all have started. Nil on other days
235 /// and when no row is timed, as Org adds the line only among timed entries.
236 public func nowIndex(now: Int) -> Int? {
237 guard isToday else { return nil }
238 return AgendaRows.nowIndex(rows, now: now)
239 }
240}
241
242public enum AgendaRows {
243 /// The days of `Agenda.list` as rows, with `events` merged in.
244 ///
245 /// - Days with no rows are left out unless `showAllDates` (`org-agenda-show-all-dates`);
246 /// today always stays.
247 /// - Today's past-scheduled entries and past deadlines (not habits) go in `overdue`,
248 /// oldest date first, Org's order among the same date.
249 /// - Other rows keep Org's order (timed by time, then untimed by urgency, habits last).
250 /// All-day events come first, timed events go among the timed entries by start time,
251 /// after entries at the same time.
252 /// - `keep` filters the Org entries (an `AgendaFilter`); events are not filtered.
253 public static func groups(
254 _ days: [AgendaDay], events: [AgendaEvent] = [], today: Int, showAllDates: Bool = false, calendar: Calendar = .current,
255 keep: (AgendaItem) -> Bool = { _ in true }
256 ) -> [AgendaDayGroup] {
257 let eventDays = events.map { ($0, $0.days(calendar: calendar)) }
258 var groups: [AgendaDayGroup] = []
259 for day in days {
260 var overdue: [AgendaRow] = []
261 var rows: [AgendaRow] = []
262 for item in day.items where keep(item) {
263 guard let row = AgendaRow(item: item, day: day.day, today: today) else { continue }
264 if day.day == today, item.habit == nil, let planning = row.planningDay, planning < today {
265 overdue.append(row)
266 } else {
267 rows.append(row)
268 }
269 }
270 overdue = overdue.enumerated().sorted { a, b in
271 a.element.planningDay! != b.element.planningDay! ? a.element.planningDay! < b.element.planningDay! : a.offset < b.offset
272 }.map(\.element)
273 let dayEvents = eventDays.filter { $0.1.contains(day.day) }.map { AgendaRow(event: $0.0, day: day.day, calendar: calendar) }
274 rows = merge(rows, events: dayEvents)
275 let group = AgendaDayGroup(day: day.day, isToday: day.day == today, header: day.header, overdue: unique(overdue), rows: unique(rows))
276 if group.isEmpty, !group.isToday, !showAllDates { continue }
277 groups.append(group)
278 }
279 return groups
280 }
281
282 /// `org` (in Org's order) with event rows added: untimed events first, timed ones before
283 /// the first untimed or habit entry, or the first timed entry starting later.
284 static func merge(_ org: [AgendaRow], events: [AgendaRow]) -> [AgendaRow] {
285 let allDay = events.filter { $0.start == nil }
286 var result = org
287 let timed = events.filter { $0.start != nil }.enumerated().sorted { a, b in
288 a.element.start! != b.element.start! ? a.element.start! < b.element.start! : a.offset < b.offset
289 }.map(\.element)
290 for event in timed {
291 let at = result.firstIndex { row in
292 if row.isEvent { return row.start.map { $0 > event.start! } ?? false }
293 guard let start = row.start, row.item?.habit == nil else { return true }
294 return start > event.start!
295 } ?? result.count
296 result.insert(event, at: at)
297 }
298 return allDay + result
299 }
300
301 /// Ids made unique: a repeat gets `#2`, `#3`.
302 static func unique(_ rows: [AgendaRow]) -> [AgendaRow] {
303 var seen: [String: Int] = [:]
304 return rows.map { row in
305 var row = row
306 let n = seen[row.id, default: 0] + 1
307 seen[row.id] = n
308 if n > 1 { row.id += "#\(n)" }
309 return row
310 }
311 }
312
313 /// See `AgendaDayGroup.nowIndex(now:)`.
314 public static func nowIndex(_ rows: [AgendaRow], now: Int) -> Int? {
315 let timed = rows.indices.filter { rows[$0].start != nil && rows[$0].item?.habit == nil }
316 guard let last = timed.last else { return nil }
317 return timed.first { rows[$0].start! > now } ?? last + 1
318 }
319}