Commit c17e16cfba
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaSnapshot.swift added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import 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. | |
| 7 | public 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 var link: AppLink? { | |
| 51 | guard let path, let offset else { return nil } | |
| 52 | return .entry(path: path, offset: offset) | |
| 53 | } | |
| 54 | ||
| 55 | /// Not done and not a calendar event. | |
| 56 | public var isOpen: Bool { !isDone && !isEvent } | |
| 57 | } | |
| 58 | ||
| 59 | public struct Day: Codable, Equatable, Sendable, Identifiable { | |
| 60 | /// As `Days` numbers it. | |
| 61 | public var day: Int | |
| 62 | /// Midnight starting the day. | |
| 63 | public var date: Date | |
| 64 | public var header: String | |
| 65 | /// Today only: deadlines and scheduled entries from before today. | |
| 66 | public var overdue: [Row] | |
| 67 | public var rows: [Row] | |
| 68 | ||
| 69 | public var id: Int { day } | |
| 70 | } | |
| 71 | ||
| 72 | public var generated: Date | |
| 73 | public var days: [Day] | |
| 74 | /// The agenda's colors the widgets use: `agenda-today`, `agenda-time`, `agenda-date`, | |
| 75 | /// `foreground`, and `agenda-background` when the theme sets one. | |
| 76 | public var colors: [String: Hex] | |
| 77 | ||
| 78 | public init(generated: Date, days: [Day], colors: [String: Hex]) { | |
| 79 | self.generated = generated | |
| 80 | self.days = days | |
| 81 | self.colors = colors | |
| 82 | } | |
| 83 | ||
| 84 | /// The groups from `today` through `dayCount - 1` days later, colored by `theme`. | |
| 85 | public init( | |
| 86 | groups: [AgendaDayGroup], today: Int, dayCount: Int = AgendaSnapshot.dayCount, theme: ThemeSpec, generated: Date, | |
| 87 | calendar: Calendar = .current | |
| 88 | ) { | |
| 89 | func row(_ row: AgendaRow, midnight: Date) -> Row { | |
| 90 | func at(_ hhmm: Int?) -> Date? { | |
| 91 | hhmm.flatMap { calendar.date(byAdding: .minute, value: ($0 / 100) * 60 + $0 % 100, to: midnight) } | |
| 92 | } | |
| 93 | let item = row.item | |
| 94 | return Row( | |
| 95 | id: row.id, gutter: row.gutter, status: row.status.rawValue, title: row.title, category: row.category, todo: row.todo, | |
| 96 | priority: row.priority, isDone: row.isDone, isEvent: row.isEvent, start: at(row.start), end: at(row.end), | |
| 97 | path: item?.path, offset: item.flatMap { $0.headingOffset ?? $0.markerOffset }, | |
| 98 | statusColor: Hex { theme.statusColor(row.status, dark: $0) }, | |
| 99 | todoColor: row.todo.flatMap { todo in Hex { theme.todoColor(todo, isDone: row.isDone, dark: $0) } }, | |
| 100 | priorityColor: row.priority.flatMap { priority in Hex { theme.priorityColor(priority, dark: $0) } }, | |
| 101 | categoryColor: Hex { dark -> RGBA? in | |
| 102 | if let event = row.event { return theme.eventColor(event, dark: dark) } | |
| 103 | return theme.categoryColor(row.category, dark: dark) | |
| 104 | } | |
| 105 | ) | |
| 106 | } | |
| 107 | days = groups.filter { $0.day >= today && $0.day < today + dayCount }.map { group in | |
| 108 | let midnight = group.date(calendar: calendar) | |
| 109 | return Day( | |
| 110 | day: group.day, date: midnight, header: group.header, | |
| 111 | overdue: group.overdue.map { row($0, midnight: midnight) }, rows: group.rows.map { row($0, midnight: midnight) } | |
| 112 | ) | |
| 113 | } | |
| 114 | var colors: [String: Hex] = [:] | |
| 115 | for key in ["agenda-today", "agenda-time", "agenda-date", "foreground", "agenda-background"] { | |
| 116 | colors[key] = Hex { theme.color(key, dark: $0) } | |
| 117 | } | |
| 118 | self.colors = colors | |
| 119 | self.generated = generated | |
| 120 | } | |
| 121 | ||
| 122 | // MARK: - What the widgets show | |
| 123 | ||
| 124 | /// The snapshot's day that `now` falls on. | |
| 125 | public func day(at now: Date, calendar: Calendar = .current) -> Day? { | |
| 126 | days.first { calendar.isDate($0.date, inSameDayAs: now) } | |
| 127 | } | |
| 128 | ||
| 129 | /// The day after `now`'s. | |
| 130 | public func day(after now: Date, calendar: Calendar = .current) -> Day? { | |
| 131 | guard let next = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) else { return nil } | |
| 132 | return day(at: next, calendar: calendar) | |
| 133 | } | |
| 134 | ||
| 135 | /// What's left of `now`'s day: its rows not done, without timed rows that have ended | |
| 136 | /// (or, with no end, started) before `now`, then the overdue entries. | |
| 137 | public func upcoming(at now: Date, calendar: Calendar = .current) -> [Row] { | |
| 138 | guard let day = day(at: now, calendar: calendar) else { return [] } | |
| 139 | return (day.rows + day.overdue).filter { row in | |
| 140 | guard !row.isDone else { return false } | |
| 141 | guard let time = row.end ?? row.start else { return true } | |
| 142 | return time >= now | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | /// Entries of `now`'s day not done, overdue ones included; calendar events don't count. | |
| 147 | public func openCount(at now: Date, calendar: Calendar = .current) -> Int { | |
| 148 | guard let day = day(at: now, calendar: calendar) else { return 0 } | |
| 149 | return (day.rows + day.overdue).filter(\.isOpen).count | |
| 150 | } | |
| 151 | ||
| 152 | public func overdueCount(at now: Date, calendar: Calendar = .current) -> Int { | |
| 153 | day(at: now, calendar: calendar)?.overdue.filter(\.isOpen).count ?? 0 | |
| 154 | } | |
| 155 | ||
| 156 | /// When what `upcoming` shows changes after `now`: a minute after each timed row of the | |
| 157 | /// day ends (or starts), and the next midnight. | |
| 158 | public func changes(after now: Date, calendar: Calendar = .current) -> [Date] { | |
| 159 | var dates = Set<Date>() | |
| 160 | for row in day(at: now, calendar: calendar).map({ $0.rows + $0.overdue }) ?? [] { | |
| 161 | if let time = row.end ?? row.start, time.addingTimeInterval(60) > now { dates.insert(time.addingTimeInterval(60)) } | |
| 162 | } | |
| 163 | if let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) { dates.insert(midnight) } | |
| 164 | return dates.sorted() | |
| 165 | } | |
| 166 | ||
| 167 | // MARK: - The file | |
| 168 | ||
| 169 | /// `Widgets/agenda.json` in the app group's container; nil without the group. | |
| 170 | public static func file(group: String = CaptureInbox.group) -> URL? { | |
| 171 | FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)? | |
| 172 | .appendingPathComponent("Widgets").appendingPathComponent("agenda.json") | |
| 173 | } | |
| 174 | ||
| 175 | public static func read(from file: URL) -> AgendaSnapshot? { | |
| 176 | guard let data = try? Data(contentsOf: file) else { return nil } | |
| 177 | return try? decoder.decode(AgendaSnapshot.self, from: data) | |
| 178 | } | |
| 179 | ||
| 180 | /// Writes the snapshot unless the file holds the same days and colors; true when it wrote. | |
| 181 | @discardableResult | |
| 182 | public func write(to file: URL) throws -> Bool { | |
| 183 | if let old = Self.read(from: file), old.days == days, old.colors == colors { return false } | |
| 184 | try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 185 | try Self.encoder.encode(self).write(to: file, options: .atomic) | |
| 186 | return true | |
| 187 | } | |
| 188 | ||
| 189 | static var encoder: JSONEncoder { | |
| 190 | let encoder = JSONEncoder() | |
| 191 | encoder.dateEncodingStrategy = .iso8601 | |
| 192 | encoder.outputFormatting = [.sortedKeys] | |
| 193 | return encoder | |
| 194 | } | |
| 195 | ||
| 196 | static var decoder: JSONDecoder { | |
| 197 | let decoder = JSONDecoder() | |
| 198 | decoder.dateDecodingStrategy = .iso8601 | |
| 199 | return decoder | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | /// The app's own links, `orgstar://`, which widgets and the Live Activity open. | |
| 204 | public enum AppLink: Equatable, Sendable { | |
| 205 | case agenda | |
| 206 | /// An Org entry: its file and an offset in it. | |
| 207 | case entry(path: String, offset: Int) | |
| 208 | /// The entry of the running clock. | |
| 209 | case clock | |
| 210 | ||
| 211 | public static let scheme = "orgstar" | |
| 212 | ||
| 213 | public var url: URL { | |
| 214 | var components = URLComponents() | |
| 215 | components.scheme = Self.scheme | |
| 216 | switch self { | |
| 217 | case .agenda: | |
| 218 | components.host = "agenda" | |
| 219 | case .clock: | |
| 220 | components.host = "clock" | |
| 221 | case .entry(let path, let offset): | |
| 222 | components.host = "entry" | |
| 223 | components.queryItems = [URLQueryItem(name: "path", value: path), URLQueryItem(name: "offset", value: String(offset))] | |
| 224 | } | |
| 225 | return components.url! | |
| 226 | } | |
| 227 | ||
| 228 | public init?(url: URL) { | |
| 229 | guard url.scheme == Self.scheme, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } | |
| 230 | func query(_ name: String) -> String? { components.queryItems?.first { $0.name == name }?.value } | |
| 231 | switch components.host { | |
| 232 | case "agenda": self = .agenda | |
| 233 | case "clock": self = .clock | |
| 234 | case "entry": | |
| 235 | guard let path = query("path"), let offset = query("offset").flatMap(Int.init) else { return nil } | |
| 236 | self = .entry(path: path, offset: offset) | |
| 237 | default: return nil | |
| 238 | } | |
| 239 | } | |
| 240 | } | |
Tests/OrgAppTests/AgendaSnapshotTests.swift added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import OrgPresentation | |
| 4 | import Testing | |
| 5 | @testable import OrgApp | |
| 6 | ||
| 7 | struct AgendaSnapshotTests { | |
| 8 | static let file = """ | |
| 9 | #+CATEGORY: life | |
| 10 | * TODO [#A] Send forms | |
| 11 | DEADLINE: <2026-10-02 Fri> | |
| 12 | * TODO Water plants | |
| 13 | SCHEDULED: <2026-10-05 Mon> | |
| 14 | * DONE Took out bins | |
| 15 | SCHEDULED: <2026-10-05 Mon> | |
| 16 | * Standup | |
| 17 | SCHEDULED: <2026-10-05 Mon 08:00> | |
| 18 | * Dentist <2026-10-05 Mon 13:50-14:30> | |
| 19 | * NEXT Call the bank | |
| 20 | SCHEDULED: <2026-10-06 Tue 09:30> | |
| 21 | * Yesterday's note <2026-10-04 Sun> | |
| 22 | * Far off <2026-10-20 Tue> | |
| 23 | """ | |
| 24 | ||
| 25 | static let today = Days.absolute(year: 2026, month: 10, day: 5) | |
| 26 | static let utc: Calendar = { | |
| 27 | var c = Calendar(identifier: .gregorian) | |
| 28 | c.timeZone = TimeZone(identifier: "UTC")! | |
| 29 | return c | |
| 30 | }() | |
| 31 | ||
| 32 | static func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { | |
| 33 | Date(timeIntervalSince1970: Double((day - 719163) * 86400 + hour * 3600 + minute * 60)) | |
| 34 | } | |
| 35 | ||
| 36 | /// The agenda from three days before today for three weeks, as the app's view may be. | |
| 37 | static func snapshot() -> AgendaSnapshot { | |
| 38 | let source = AgendaSource(path: "/notes/life.org", text: file, defaults: OrgSettings.default.withTodoKeywords("TODO NEXT | DONE")!) | |
| 39 | let days = Agenda.list([source], start: today - 3, days: 21, today: today, now: (10, 0)) | |
| 40 | let event = AgendaEvent( | |
| 41 | id: "e1", title: "Lunch", calendarID: "c", calendarTitle: "Home", color: RGBA(hex: "#336699"), | |
| 42 | start: date(today, 12), end: date(today, 13), isAllDay: false | |
| 43 | ) | |
| 44 | let groups = AgendaRows.groups(days, events: [event], today: today, calendar: utc) | |
| 45 | return AgendaSnapshot(groups: groups, today: today, theme: .default, generated: date(today, 10), calendar: utc) | |
| 46 | } | |
| 47 | ||
| 48 | @Test func holdsTodayAndTheNextDaysOnly() { | |
| 49 | let snapshot = Self.snapshot() | |
| 50 | #expect(snapshot.days.map(\.day) == [Self.today, Self.today + 1]) | |
| 51 | let today = snapshot.days[0] | |
| 52 | #expect(today.date == Self.date(Self.today, 0)) | |
| 53 | #expect(today.overdue.map(\.title) == ["Send forms"]) | |
| 54 | #expect(today.rows.map(\.title) == ["Standup", "Lunch", "Dentist", "Water plants", "Took out bins"]) | |
| 55 | #expect(snapshot.days[1].rows.map(\.title) == ["Call the bank"]) | |
| 56 | } | |
| 57 | ||
| 58 | @Test func rowsCarryTimesLinksAndColors() throws { | |
| 59 | let snapshot = Self.snapshot() | |
| 60 | let rows = snapshot.days[0].rows + snapshot.days[0].overdue | |
| 61 | let dentist = try #require(rows.first { $0.title == "Dentist" }) | |
| 62 | #expect(dentist.gutter == "13:50–14:30") | |
| 63 | #expect(dentist.start == Self.date(Self.today, 13, 50)) | |
| 64 | #expect(dentist.end == Self.date(Self.today, 14, 30)) | |
| 65 | #expect(dentist.path == "/notes/life.org") | |
| 66 | #expect(dentist.link == .entry(path: "/notes/life.org", offset: dentist.offset!)) | |
| 67 | let forms = try #require(rows.first { $0.title == "Send forms" }) | |
| 68 | #expect(forms.status == "overdue") | |
| 69 | #expect(forms.todo == "TODO") | |
| 70 | #expect(forms.priority == "A") | |
| 71 | #expect(forms.todoColor == .init(light: "#d70015", dark: "#ff6961")) | |
| 72 | #expect(forms.priorityColor == .init(light: "#d70015", dark: "#ff6961")) | |
| 73 | #expect(forms.statusColor == .init(light: "#d70015", dark: "#ff6961")) | |
| 74 | let lunch = try #require(rows.first { $0.title == "Lunch" }) | |
| 75 | #expect(lunch.isEvent && lunch.link == nil) | |
| 76 | #expect(lunch.categoryColor == .init(light: "#336699", dark: "#336699")) | |
| 77 | #expect(snapshot.colors["agenda-today"] == .init(light: "#007aff", dark: "#0a84ff")) | |
| 78 | #expect(snapshot.colors["agenda-background"] == nil) | |
| 79 | } | |
| 80 | ||
| 81 | @Test func encodesAndDecodes() throws { | |
| 82 | let snapshot = Self.snapshot() | |
| 83 | let data = try AgendaSnapshot.encoder.encode(snapshot) | |
| 84 | #expect(try AgendaSnapshot.decoder.decode(AgendaSnapshot.self, from: data) == snapshot) | |
| 85 | } | |
| 86 | ||
| 87 | @Test func writesOnlyWhatChanged() throws { | |
| 88 | let folder = try Folder() | |
| 89 | let file = folder.url.appendingPathComponent("Widgets/agenda.json") | |
| 90 | var snapshot = Self.snapshot() | |
| 91 | #expect(try snapshot.write(to: file)) | |
| 92 | #expect(AgendaSnapshot.read(from: file) == snapshot) | |
| 93 | snapshot.generated = snapshot.generated.addingTimeInterval(60) | |
| 94 | #expect(try !snapshot.write(to: file)) | |
| 95 | snapshot.days.removeLast() | |
| 96 | #expect(try snapshot.write(to: file)) | |
| 97 | #expect(AgendaSnapshot.read(from: file)?.days.count == 1) | |
| 98 | } | |
| 99 | ||
| 100 | @Test func whatIsLeftOfToday() { | |
| 101 | let snapshot = Self.snapshot() | |
| 102 | let morning = Self.date(Self.today, 7) | |
| 103 | #expect(snapshot.upcoming(at: morning, calendar: Self.utc).map(\.title) == ["Standup", "Lunch", "Dentist", "Water plants", "Send forms"]) | |
| 104 | #expect(snapshot.upcoming(at: Self.date(Self.today, 14), calendar: Self.utc).map(\.title) == ["Dentist", "Water plants", "Send forms"]) | |
| 105 | #expect(snapshot.openCount(at: morning, calendar: Self.utc) == 4) | |
| 106 | #expect(snapshot.overdueCount(at: morning, calendar: Self.utc) == 1) | |
| 107 | #expect(snapshot.day(after: morning, calendar: Self.utc)?.day == Self.today + 1) | |
| 108 | #expect(snapshot.upcoming(at: Self.date(Self.today + 3, 9), calendar: Self.utc) == []) | |
| 109 | #expect(snapshot.changes(after: Self.date(Self.today, 12, 30), calendar: Self.utc) == [ | |
| 110 | Self.date(Self.today, 13, 1), Self.date(Self.today, 14, 31), Self.date(Self.today + 1, 0), | |
| 111 | ]) | |
| 112 | } | |
| 113 | ||
| 114 | @Test func linksRoundTrip() { | |
| 115 | for link in [AppLink.agenda, .clock, .entry(path: "/notes/a b&c+d/ünï.org", offset: 42)] { | |
| 116 | #expect(AppLink(url: link.url) == link) | |
| 117 | } | |
| 118 | #expect(AppLink.agenda.url.absoluteString == "orgstar://agenda") | |
| 119 | #expect(AppLink(url: URL(string: "org-protocol://capture?body=x")!) == nil) | |
| 120 | #expect(AppLink(url: URL(string: "orgstar://entry?path=/a.org")!) == nil) | |
| 121 | } | |
| 122 | } | |