Sources/OrgApp/AgendaSnapshot.swift
272 lines · 11303 bytes
17 symbols in this file
1import Foundation
2import OrgCore
3import OrgPresentation
4
5/// The next days of the agenda for widgets, which can't read the folders: the app writes it
6/// as JSON into the app group's container, the widgets read it.
7public struct AgendaSnapshot: Codable, Equatable, Sendable {
8 /// Days from today the snapshot holds.
9 public static let dayCount = 7
10
11 /// A color in light and dark appearance, `#rrggbb` or `#rrggbbaa`.
12 public struct Hex: Codable, Equatable, Sendable {
13 public var light: String
14 public var dark: String
15
16 public init(light: String, dark: String) {
17 self.light = light
18 self.dark = dark
19 }
20
21 init?(_ color: (Bool) -> RGBA?) {
22 guard let light = color(false) ?? color(true), let dark = color(true) ?? color(false) else { return nil }
23 self.init(light: light.hex, dark: dark.hex)
24 }
25 }
26
27 public struct Row: Codable, Equatable, Sendable, Identifiable {
28 public var id: String
29 /// `AgendaRow.gutter`: the time or a short status.
30 public var gutter: String
31 /// `AgendaRow.Status`'s raw value.
32 public var status: String
33 public var title: String
34 public var category: String
35 public var todo: String?
36 public var priority: String?
37 public var isDone: Bool
38 public var isEvent: Bool
39 /// When a timed row starts and ends.
40 public var start: Date?
41 public var end: Date?
42 /// The Org entry, for a link to it.
43 public var path: String?
44 public var offset: Int?
45 public var statusColor: Hex?
46 public var todoColor: Hex?
47 public var priorityColor: Hex?
48 public var categoryColor: Hex?
49
50 public init(
51 id: String, gutter: String, status: String, title: String, category: String, todo: String?, priority: String?, isDone: Bool,
52 isEvent: Bool, start: Date?, end: Date?, path: String?, offset: Int?, statusColor: Hex?, todoColor: Hex?, priorityColor: Hex?,
53 categoryColor: Hex?
54 ) {
55 self.id = id
56 self.gutter = gutter
57 self.status = status
58 self.title = title
59 self.category = category
60 self.todo = todo
61 self.priority = priority
62 self.isDone = isDone
63 self.isEvent = isEvent
64 self.start = start
65 self.end = end
66 self.path = path
67 self.offset = offset
68 self.statusColor = statusColor
69 self.todoColor = todoColor
70 self.priorityColor = priorityColor
71 self.categoryColor = categoryColor
72 }
73
74 public var link: AppLink? {
75 guard let path, let offset else { return nil }
76 return .entry(path: path, offset: offset)
77 }
78
79 /// Not done and not a calendar event.
80 public var isOpen: Bool { !isDone && !isEvent }
81 }
82
83 public struct Day: Codable, Equatable, Sendable, Identifiable {
84 /// As `Days` numbers it.
85 public var day: Int
86 /// Midnight starting the day.
87 public var date: Date
88 public var header: String
89 /// Today only: deadlines and scheduled entries from before today.
90 public var overdue: [Row]
91 public var rows: [Row]
92
93 public var id: Int { day }
94
95 public init(day: Int, date: Date, header: String, overdue: [Row], rows: [Row]) {
96 self.day = day
97 self.date = date
98 self.header = header
99 self.overdue = overdue
100 self.rows = rows
101 }
102 }
103
104 public var generated: Date
105 public var days: [Day]
106 /// The agenda's colors the widgets use: `agenda-today`, `agenda-time`, `agenda-date`,
107 /// `foreground`, and `agenda-background` when the theme sets one.
108 public var colors: [String: Hex]
109
110 public init(generated: Date, days: [Day], colors: [String: Hex]) {
111 self.generated = generated
112 self.days = days
113 self.colors = colors
114 }
115
116 /// The groups from `today` through `dayCount - 1` days later, colored by `theme`.
117 public init(
118 groups: [AgendaDayGroup], today: Int, dayCount: Int = AgendaSnapshot.dayCount, theme: ThemeSpec, generated: Date,
119 calendar: Calendar = .current
120 ) {
121 func row(_ row: AgendaRow, midnight: Date) -> Row {
122 func at(_ hhmm: Int?) -> Date? {
123 hhmm.flatMap { calendar.date(byAdding: .minute, value: ($0 / 100) * 60 + $0 % 100, to: midnight) }
124 }
125 let item = row.item
126 return Row(
127 id: row.id, gutter: row.gutter, status: row.status.rawValue, title: row.title, category: row.category, todo: row.todo,
128 priority: row.priority, isDone: row.isDone, isEvent: row.isEvent, start: at(row.start), end: at(row.end),
129 path: item?.path, offset: item.flatMap { $0.headingOffset ?? $0.markerOffset },
130 statusColor: Hex { theme.statusColor(row.status, dark: $0) },
131 todoColor: row.todo.flatMap { todo in Hex { theme.todoColor(todo, isDone: row.isDone, dark: $0) } },
132 priorityColor: row.priority.flatMap { priority in Hex { theme.priorityColor(priority, dark: $0) } },
133 categoryColor: Hex { dark -> RGBA? in
134 if let event = row.event { return theme.eventColor(event, dark: dark) }
135 return theme.categoryColor(row.category, dark: dark)
136 }
137 )
138 }
139 days = groups.filter { $0.day >= today && $0.day < today + dayCount }.map { group in
140 let midnight = group.date(calendar: calendar)
141 return Day(
142 day: group.day, date: midnight, header: group.header,
143 overdue: group.overdue.map { row($0, midnight: midnight) }, rows: group.rows.map { row($0, midnight: midnight) }
144 )
145 }
146 var colors: [String: Hex] = [:]
147 for key in ["agenda-today", "agenda-time", "agenda-date", "foreground", "agenda-background"] {
148 colors[key] = Hex { theme.color(key, dark: $0) }
149 }
150 self.colors = colors
151 self.generated = generated
152 }
153
154 // MARK: - What the widgets show
155
156 /// The snapshot's day that `now` falls on.
157 public func day(at now: Date, calendar: Calendar = .current) -> Day? {
158 days.first { calendar.isDate($0.date, inSameDayAs: now) }
159 }
160
161 /// The day after `now`'s.
162 public func day(after now: Date, calendar: Calendar = .current) -> Day? {
163 guard let next = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) else { return nil }
164 return day(at: next, calendar: calendar)
165 }
166
167 /// What's left of `now`'s day: its rows not done, without timed rows that have ended
168 /// (or, with no end, started) before `now`, then the overdue entries.
169 public func upcoming(at now: Date, calendar: Calendar = .current) -> [Row] {
170 guard let day = day(at: now, calendar: calendar) else { return [] }
171 return (day.rows + day.overdue).filter { row in
172 guard !row.isDone else { return false }
173 guard let time = row.end ?? row.start else { return true }
174 return time >= now
175 }
176 }
177
178 /// Entries of `now`'s day not done, overdue ones included; calendar events don't count.
179 public func openCount(at now: Date, calendar: Calendar = .current) -> Int {
180 guard let day = day(at: now, calendar: calendar) else { return 0 }
181 return (day.rows + day.overdue).filter(\.isOpen).count
182 }
183
184 public func overdueCount(at now: Date, calendar: Calendar = .current) -> Int {
185 day(at: now, calendar: calendar)?.overdue.filter(\.isOpen).count ?? 0
186 }
187
188 /// When what `upcoming` shows changes after `now`: a minute after each timed row of the
189 /// day ends (or starts), and the next midnight.
190 public func changes(after now: Date, calendar: Calendar = .current) -> [Date] {
191 var dates = Set<Date>()
192 for row in day(at: now, calendar: calendar).map({ $0.rows + $0.overdue }) ?? [] {
193 if let time = row.end ?? row.start, time.addingTimeInterval(60) > now { dates.insert(time.addingTimeInterval(60)) }
194 }
195 if let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) { dates.insert(midnight) }
196 return dates.sorted()
197 }
198
199 // MARK: - The file
200
201 /// `Widgets/agenda.json` in the app group's container; nil without the group.
202 public static func file(group: String = CaptureInbox.group) -> URL? {
203 FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)?
204 .appendingPathComponent("Widgets").appendingPathComponent("agenda.json")
205 }
206
207 public static func read(from file: URL) -> AgendaSnapshot? {
208 guard let data = try? Data(contentsOf: file) else { return nil }
209 return try? decoder.decode(AgendaSnapshot.self, from: data)
210 }
211
212 /// Writes the snapshot unless the file holds the same days and colors; true when it wrote.
213 @discardableResult
214 public func write(to file: URL) throws -> Bool {
215 if let old = Self.read(from: file), old.days == days, old.colors == colors { return false }
216 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
217 try Self.encoder.encode(self).write(to: file, options: .atomic)
218 return true
219 }
220
221 static var encoder: JSONEncoder {
222 let encoder = JSONEncoder()
223 encoder.dateEncodingStrategy = .iso8601
224 encoder.outputFormatting = [.sortedKeys]
225 return encoder
226 }
227
228 static var decoder: JSONDecoder {
229 let decoder = JSONDecoder()
230 decoder.dateDecodingStrategy = .iso8601
231 return decoder
232 }
233}
234
235/// The app's own links, `orgstar://`, which widgets and the Live Activity open.
236public enum AppLink: Equatable, Sendable {
237 case agenda
238 /// An Org entry: its file and an offset in it.
239 case entry(path: String, offset: Int)
240 /// The entry of the running clock.
241 case clock
242
243 public static let scheme = "orgstar"
244
245 public var url: URL {
246 var components = URLComponents()
247 components.scheme = Self.scheme
248 switch self {
249 case .agenda:
250 components.host = "agenda"
251 case .clock:
252 components.host = "clock"
253 case .entry(let path, let offset):
254 components.host = "entry"
255 components.queryItems = [URLQueryItem(name: "path", value: path), URLQueryItem(name: "offset", value: String(offset))]
256 }
257 return components.url!
258 }
259
260 public init?(url: URL) {
261 guard url.scheme == Self.scheme, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
262 func query(_ name: String) -> String? { components.queryItems?.first { $0.name == name }?.value }
263 switch components.host {
264 case "agenda": self = .agenda
265 case "clock": self = .clock
266 case "entry":
267 guard let path = query("path"), let offset = query("offset").flatMap(Int.init) else { return nil }
268 self = .entry(path: path, offset: offset)
269 default: return nil
270 }
271 }
272}