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 | } | ||