Habit graphs !31
8 files changed, +463 −14
Layout: unified · split
Sources/OrgCore/Agenda/Agenda.swift +15 −7
| @@ -37,6 +37,8 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 37 | 37 | public let urgency: Int |
| 38 | 38 | /// `APPT_WARNTIME`: minutes of warning before the entry's time. |
| 39 | 39 | public let warntime: Int? |
| 40 | /// The habit whose consistency graph goes on the line (scheduled habits). | |
| 41 | public let habit: Habit? | |
| 40 | 42 | /// The line as org prints it, with one space before the tags. |
| 41 | 43 | public let line: String |
| 42 | 44 | } |
| @@ -144,11 +146,15 @@ public enum Agenda { | ||
| 144 | 146 | continue |
| 145 | 147 | } |
| 146 | 148 | if heading.isDone, schedule != current { continue } |
| 147 | let time: Dotime? = current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) | |
| 148 | let extra = isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: " | |
| 149 | // org-habit: habits only on today's agenda, and not when done. | |
| 150 | if heading.isHabit, heading.isDone || !isToday { continue } | |
| 151 | let time: Dotime? = !heading.isHabit && current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) | |
| 152 | let extra = heading.isHabit ? "" : isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: " | |
| 153 | let habit = heading.isHabit ? heading.habit : nil | |
| 149 | 154 | items.append(format( |
| 150 | 155 | source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra, |
| 151 | dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { 99 + diff + $0 } | |
| 156 | dotime: time, removing: nil, trailing: options.gridTrailing, habit: habit, | |
| 157 | urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 } | |
| 152 | 158 | )) |
| 153 | 159 | } |
| 154 | 160 | return items |
| @@ -204,7 +210,8 @@ public enum Agenda { | ||
| 204 | 210 | let heading = source.headings[stamp.heading] |
| 205 | 211 | items.append(format( |
| 206 | 212 | source, heading, kind: .timestamp, marker: stamp.offset, extra: "", |
| 207 | dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, urgency: { $0 } | |
| 213 | dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, | |
| 214 | urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 } | |
| 208 | 215 | )) |
| 209 | 216 | } |
| 210 | 217 | return items |
| @@ -240,7 +247,7 @@ public enum Agenda { | ||
| 240 | 247 | static func format( |
| 241 | 248 | _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, |
| 242 | 249 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true, |
| 243 | urgency: (Int) -> Int | |
| 250 | habit: Habit? = nil, urgency: (Int) -> Int | |
| 244 | 251 | ) -> AgendaItem { |
| 245 | 252 | var txt = heading.head.trimmingCharacters(in: .whitespaces) |
| 246 | 253 | // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. |
| @@ -279,7 +286,7 @@ public enum Agenda { | ||
| 279 | 286 | kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, |
| 280 | 287 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 281 | 288 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 282 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, | |
| 289 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit, | |
| 283 | 290 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) |
| 284 | 291 | ) |
| 285 | 292 | } |
| @@ -394,7 +401,7 @@ public enum Agenda { | ||
| 394 | 401 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) |
| 395 | 402 | return AgendaItem( |
| 396 | 403 | kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, |
| 397 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, | |
| 404 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, habit: nil, | |
| 398 | 405 | line: " " + pad("", 12) + pad(timed.time, 12) + txt |
| 399 | 406 | ) |
| 400 | 407 | } |
| @@ -410,6 +417,7 @@ public enum Agenda { | ||
| 410 | 417 | /// `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, stably. |
| 411 | 418 | static func sort(_ items: [AgendaItem]) -> [AgendaItem] { |
| 412 | 419 | items.enumerated().sorted { a, b in |
| 420 | if (a.element.habit == nil) != (b.element.habit == nil) { return a.element.habit == nil } | |
| 413 | 421 | let ta = a.element.timeOfDay ?? 9901 |
| 414 | 422 | let tb = b.element.timeOfDay ?? 9901 |
| 415 | 423 | if ta != tb { return ta < tb } |
Sources/OrgCore/Agenda/AgendaSource.swift +12 −1
| @@ -35,6 +35,10 @@ public struct AgendaSource: Sendable { | ||
| 35 | 35 | /// The planning timestamp without brackets, and the position of its `<`. |
| 36 | 36 | let scheduled: (raw: String, offset: Int)? |
| 37 | 37 | let deadline: (raw: String, offset: Int)? |
| 38 | /// `STYLE: habit`. | |
| 39 | let isHabit: Bool | |
| 40 | /// `org-habit-parse-todo`, nil where it signals an error. | |
| 41 | let habit: Habit? | |
| 38 | 42 | } |
| 39 | 43 | |
| 40 | 44 | struct Stamp: Sendable { |
| @@ -176,13 +180,20 @@ public struct AgendaSource: Sendable { | ||
| 176 | 180 | } |
| 177 | 181 | } |
| 178 | 182 | let ownStamps = sectionStamps(index) |
| 183 | let isHabit = properties["STYLE"]?.trimmingCharacters(in: .whitespaces) == "habit" | |
| 184 | var habit: Habit? | |
| 185 | if isHabit, let scheduled { | |
| 186 | let end = index + 1 < model.headings.count ? model.headings[index + 1].headingRange.lowerBound : ns.length | |
| 187 | let entry = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: end - info.headingRange.lowerBound)) | |
| 188 | habit = Habit.parse(entry: entry, scheduled: scheduled.0, doneKeywords: settings.todoSequences.flatMap { $0.done.map(\.name) }) | |
| 189 | } | |
| 179 | 190 | headings.append(Heading( |
| 180 | 191 | start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index], |
| 181 | 192 | title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags, |
| 182 | 193 | properties: properties, planning: planningValues, |
| 183 | 194 | firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") }, |
| 184 | 195 | line: line.trimmingCharacters(in: .newlines), head: head, todo: info.todo, isDone: info.isDone, |
| 185 | tags: tags, category: category, scheduled: scheduled, deadline: deadline | |
| 196 | tags: tags, category: category, scheduled: scheduled, deadline: deadline, isHabit: isHabit, habit: habit | |
| 186 | 197 | )) |
| 187 | 198 | } |
| 188 | 199 | self.headings = headings |
Sources/OrgCore/Agenda/Habit.swift added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// An entry with `STYLE: habit` (org-habit.el, Org 9.8.7), as `org-habit-parse-todo` reads | |
| 4 | /// it, with `org-log-states-order-reversed` t. | |
| 5 | public struct Habit: Sendable, Equatable { | |
| 6 | /// Days are absolute day numbers (`Days`). | |
| 7 | public let scheduled: Int | |
| 8 | public let scheduledRepeat: Int | |
| 9 | let deadlineDay: Int? | |
| 10 | let deadlineRepeatDays: Int? | |
| 11 | /// Days the entry was done, at most `preceding + following` of them, oldest first. | |
| 12 | public let doneDates: [Int] | |
| 13 | /// `+`, `++` or `.+`. | |
| 14 | public let repeatType: String | |
| 15 | ||
| 16 | public static let precedingDays = 21 | |
| 17 | public static let followingDays = 7 | |
| 18 | ||
| 19 | /// `org-habit-deadline`. | |
| 20 | public var deadline: Int { | |
| 21 | deadlineDay ?? (deadlineRepeatDays != nil ? scheduled + scheduledRepeat - 1 : scheduled) | |
| 22 | } | |
| 23 | ||
| 24 | /// `org-habit-deadline-repeat`. | |
| 25 | public var deadlineRepeat: Int { deadlineRepeatDays ?? scheduledRepeat } | |
| 26 | ||
| 27 | static let repeatRegex = try! NSRegularExpression(pattern: "[+.]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?") | |
| 28 | static let durationRegex = try! NSRegularExpression(pattern: "([0-9]+)([dwmy])") | |
| 29 | ||
| 30 | /// `org-habit-duration-to-days`. | |
| 31 | static func days(_ s: String) -> Int? { | |
| 32 | let ns = s as NSString | |
| 33 | guard let m = durationRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil } | |
| 34 | let n = Double(ns.substring(with: m.range(at: 1)))! | |
| 35 | let factor: Double = switch ns.substring(with: m.range(at: 2)) { | |
| 36 | case "d": 1 | |
| 37 | case "w": 7 | |
| 38 | case "m": 30.4 | |
| 39 | default: 365.25 | |
| 40 | } | |
| 41 | return Int((n * factor).rounded(.down)) | |
| 42 | } | |
| 43 | ||
| 44 | /// `org-habit-parse-todo` for the entry whose text is `entry` (heading line to the next | |
| 45 | /// heading) and whose SCHEDULED timestamp is `scheduled` (without brackets). Nil where | |
| 46 | /// org signals an error. | |
| 47 | public static func parse(entry: String, scheduled: String, doneKeywords: [String]) -> Habit? { | |
| 48 | guard let day = Days.absolute(of: scheduled) else { return nil } | |
| 49 | let sns = scheduled as NSString | |
| 50 | guard let rm = repeatRegex.firstMatch(in: scheduled, range: NSRange(location: 0, length: sns.length)) else { return nil } | |
| 51 | let repeat_ = sns.substring(with: rm.range) | |
| 52 | guard let srDays = days(repeat_), srDays > 0 else { return nil } | |
| 53 | let type = String(repeat_.prefix { $0 == "." || $0 == "+" }) | |
| 54 | var deadline: Int? | |
| 55 | var drDays: Int? | |
| 56 | if let slash = repeat_.range(of: "/[0-9]+[dwmy]", options: .regularExpression) { | |
| 57 | guard let dr = days(String(repeat_[slash].dropFirst())), dr > srDays else { return nil } | |
| 58 | drDays = dr | |
| 59 | deadline = day + dr - srDays | |
| 60 | } | |
| 61 | let done = doneKeywords.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|") | |
| 62 | let inactive = "\\[([0-9]{4}-[0-9]{2}-[0-9]{2} ?[^\\]\\r\\n>]*?)\\]" | |
| 63 | // `org-log-note-headings` `done` is "CLOSING NOTE %t". | |
| 64 | let pattern = "^[ \\t]*-[ \\t]+(?:State \"(?:\(done))\".*\(inactive)|CLOSING NOTE \(inactive))" | |
| 65 | let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive]) | |
| 66 | let ens = entry as NSString | |
| 67 | var closed: [Int] = [] | |
| 68 | for m in regex.matches(in: entry, range: NSRange(location: 0, length: ens.length)) { | |
| 69 | guard closed.count < precedingDays + followingDays else { break } | |
| 70 | let group = m.range(at: 1).location != NSNotFound ? m.range(at: 1) : m.range(at: 2) | |
| 71 | if let d = Days.absolute(of: ens.substring(with: group)) { closed.insert(d, at: 0) } | |
| 72 | } | |
| 73 | return Habit(scheduled: day, scheduledRepeat: srDays, deadlineDay: deadline, deadlineRepeatDays: drDays, doneDates: closed, repeatType: type) | |
| 74 | } | |
| 75 | ||
| 76 | /// `org-habit-get-urgency`. | |
| 77 | public func urgency(today: Int) -> Int { | |
| 78 | var pri = 1000 + (today - scheduled) * 10 | |
| 79 | if scheduled != deadline, today == deadline { pri += 50 } | |
| 80 | let slip = today - (deadline - 1) | |
| 81 | pri += slip > 0 ? slip * 100 : slip * 10 | |
| 82 | return pri | |
| 83 | } | |
| 84 | ||
| 85 | public enum Face: String, Sendable { | |
| 86 | case clear, clearFuture, ready, readyFuture, alert, alertFuture, overdue, overdueFuture | |
| 87 | } | |
| 88 | ||
| 89 | public struct Cell: Sendable, Equatable { | |
| 90 | public let day: Int | |
| 91 | /// `*` done, `!` today, or a space. | |
| 92 | public let glyph: Character | |
| 93 | public let face: Face | |
| 94 | } | |
| 95 | ||
| 96 | /// `org-habit-get-faces`. | |
| 97 | func faces(now: Int, scheduledDays: Int?, done: Bool) -> (Face, Face) { | |
| 98 | let scheduled = scheduledDays ?? self.scheduled | |
| 99 | let deadline = scheduledDays.map { $0 + deadlineRepeat - scheduledRepeat } ?? self.deadline | |
| 100 | if now < scheduled { return (.clear, .clearFuture) } | |
| 101 | if now < deadline { return (.ready, .readyFuture) } | |
| 102 | if now == deadline { return done ? (.ready, .readyFuture) : (.alert, .alertFuture) } | |
| 103 | return (.overdue, .overdueFuture) | |
| 104 | } | |
| 105 | ||
| 106 | /// `org-habit-build-graph` from `today - preceding` to `today + following` (exclusive), | |
| 107 | /// as `org-habit-insert-consistency-graphs` calls it. | |
| 108 | public func graph(today: Int, preceding: Int = precedingDays, following: Int = followingDays) -> [Cell] { | |
| 109 | let allDone = doneDates.sorted() | |
| 110 | var doneIndex = 0 | |
| 111 | var lastDone: Int? | |
| 112 | let first = today - preceding | |
| 113 | while doneIndex < allDone.count, allDone[doneIndex] < first { | |
| 114 | lastDone = allDone[doneIndex] | |
| 115 | doneIndex += 1 | |
| 116 | } | |
| 117 | var cells: [Cell] = [] | |
| 118 | for day in first..<(today + following) { | |
| 119 | let past = day < today | |
| 120 | let isToday = day == today | |
| 121 | let isDone = doneIndex < allDone.count && allDone[doneIndex] == day | |
| 122 | let faces: (Face, Face) | |
| 123 | if past, lastDone == nil, !(scheduled < today) { | |
| 124 | faces = allDone.first == day ? (.ready, .readyFuture) : (.clear, .clearFuture) | |
| 125 | } else { | |
| 126 | var scheduledDays: Int? | |
| 127 | if past, let last = lastDone { | |
| 128 | let remaining = allDone.count - doneIndex | |
| 129 | if remaining == 0 { | |
| 130 | scheduledDays = scheduled | |
| 131 | } else if repeatType == ".+" { | |
| 132 | scheduledDays = last + scheduledRepeat | |
| 133 | } else if repeatType == "+" { | |
| 134 | scheduledDays = scheduled - remaining * scheduledRepeat | |
| 135 | } else { | |
| 136 | let firstDone = allDone[0] | |
| 137 | let shift = ((scheduled - firstDone) % scheduledRepeat + scheduledRepeat) % scheduledRepeat | |
| 138 | var s = (shift == 0 ? scheduledRepeat : shift) + firstDone | |
| 139 | if firstDone != last { | |
| 140 | for done in allDone.dropFirst() { | |
| 141 | s += (1 + max(done - s, 0) / scheduledRepeat) * scheduledRepeat | |
| 142 | if done == last { break } | |
| 143 | } | |
| 144 | } | |
| 145 | scheduledDays = s | |
| 146 | } | |
| 147 | } | |
| 148 | faces = self.faces(now: day, scheduledDays: scheduledDays, done: isDone) | |
| 149 | } | |
| 150 | var glyph: Character = " " | |
| 151 | var marked = false | |
| 152 | if isDone { | |
| 153 | glyph = "*" | |
| 154 | marked = true | |
| 155 | while doneIndex < allDone.count, allDone[doneIndex] == day { | |
| 156 | lastDone = day | |
| 157 | doneIndex += 1 | |
| 158 | } | |
| 159 | } else if isToday { | |
| 160 | glyph = "!" | |
| 161 | } | |
| 162 | var face = past || isToday ? faces.0 : faces.1 | |
| 163 | if past, face != .overdue, !marked { face = faces.1 } | |
| 164 | cells.append(Cell(day: day, glyph: glyph, face: face)) | |
| 165 | } | |
| 166 | return cells | |
| 167 | } | |
| 168 | ||
| 169 | /// The agenda line with the graph at `org-habit-graph-column` (40), as | |
| 170 | /// `org-habit-insert-consistency-graphs` puts it. | |
| 171 | public static func insertGraph(_ cells: [Cell], into line: String, column: Int = 40) -> String { | |
| 172 | var chars = Array(line) | |
| 173 | var width = 0 | |
| 174 | var index = 0 | |
| 175 | while index < chars.count, width < column { | |
| 176 | width += displayWidth(of: chars[index]) | |
| 177 | index += 1 | |
| 178 | } | |
| 179 | if width < column { chars += Array(repeating: " ", count: column - width); index = chars.count } | |
| 180 | let remove = Swift.min(precedingDays + followingDays + 1, chars.count - index) | |
| 181 | chars.removeSubrange(index..<(index + remove)) | |
| 182 | // build-graph's vector has one more cell than it fills. | |
| 183 | chars.insert(contentsOf: cells.map(\.glyph) + [" "], at: index) | |
| 184 | return String(chars) | |
| 185 | } | |
| 186 | } | |
Sources/Orgstar/AgendaView.swift +45 −3
| @@ -79,7 +79,7 @@ struct AgendaView: View { | ||
| 79 | 79 | if isAgenda { |
| 80 | 80 | ForEach(agenda.days, id: \.day) { day in |
| 81 | 81 | Section { |
| 82 | rows(day.items, prefix: "\(day.day)") | |
| 82 | rows(day.items, prefix: "\(day.day)", day: day.day) | |
| 83 | 83 | } header: { |
| 84 | 84 | Text(day.header) |
| 85 | 85 | .font(.headline) |
| @@ -172,9 +172,9 @@ struct AgendaView: View { | ||
| 172 | 172 | } |
| 173 | 173 | |
| 174 | 174 | @ViewBuilder |
| 175 | private func rows(_ items: [AgendaItem], prefix: String) -> some View { | |
| 175 | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { | |
| 176 | 176 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 177 | AgendaRow(item: item) | |
| 177 | AgendaRow(item: item, day: day) | |
| 178 | 178 | .tag("\(prefix):\(index)") |
| 179 | 179 | .selectionDisabled(item.path == nil) |
| 180 | 180 | } |
| @@ -316,6 +316,8 @@ struct AgendaView: View { | ||
| 316 | 316 | |
| 317 | 317 | struct AgendaRow: View { |
| 318 | 318 | let item: AgendaItem |
| 319 | /// The agenda day the row is on, for habit graphs. | |
| 320 | var day: Int? | |
| 319 | 321 | |
| 320 | 322 | var body: some View { |
| 321 | 323 | switch item.kind { |
| @@ -356,6 +358,9 @@ struct AgendaRow: View { | ||
| 356 | 358 | title |
| 357 | 359 | .lineLimit(1) |
| 358 | 360 | Spacer(minLength: 8) |
| 361 | if let habit = item.habit, let day { | |
| 362 | HabitGraph(cells: habit.graph(today: day)) | |
| 363 | } | |
| 359 | 364 | if !item.tags.isEmpty { |
| 360 | 365 | Text(":" + item.tags.joined(separator: ":") + ":") |
| 361 | 366 | .font(.caption) |
| @@ -386,3 +391,40 @@ struct AgendaRow: View { | ||
| 386 | 391 | return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary) |
| 387 | 392 | } |
| 388 | 393 | } |
| 394 | ||
| 395 | /// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today. | |
| 396 | struct HabitGraph: View { | |
| 397 | let cells: [Habit.Cell] | |
| 398 | ||
| 399 | var body: some View { | |
| 400 | HStack(spacing: 1) { | |
| 401 | ForEach(cells, id: \.day) { cell in | |
| 402 | Text(String(cell.glyph)) | |
| 403 | .font(.system(size: 9, weight: .bold, design: .monospaced)) | |
| 404 | .frame(width: 8, height: 14) | |
| 405 | .background(Self.color(cell.face)) | |
| 406 | .help(Self.label(cell)) | |
| 407 | } | |
| 408 | } | |
| 409 | .accessibilityElement(children: .ignore) | |
| 410 | .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days") | |
| 411 | } | |
| 412 | ||
| 413 | static func color(_ face: Habit.Face) -> Color { | |
| 414 | switch face { | |
| 415 | case .clear: .blue.opacity(0.6) | |
| 416 | case .clearFuture: .blue.opacity(0.3) | |
| 417 | case .ready: .green.opacity(0.7) | |
| 418 | case .readyFuture: .green.opacity(0.35) | |
| 419 | case .alert: .yellow.opacity(0.8) | |
| 420 | case .alertFuture: .yellow.opacity(0.4) | |
| 421 | case .overdue: .red.opacity(0.7) | |
| 422 | case .overdueFuture: .red.opacity(0.35) | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | static func label(_ cell: Habit.Cell) -> String { | |
| 427 | let date = Days.date(cell.day) | |
| 428 | return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "") | |
| 429 | } | |
| 430 | } | |
Tests/OrgCoreTests/AgendaTests.swift +5 −3
| @@ -29,6 +29,7 @@ enum AgendaOracle { | ||
| 29 | 29 | ;;; -*- lexical-binding: t -*- |
| 30 | 30 | (require 'org) |
| 31 | 31 | (require 'org-agenda) |
| 32 | (require 'org-habit) | |
| 32 | 33 | (require 'json) |
| 33 | 34 | (setq org-todo-keywords '((sequence \#(words))) |
| 34 | 35 | org-agenda-files '(\#(fileList))) |
| @@ -115,7 +116,7 @@ enum AgendaOracle { | ||
| 115 | 116 | #expect(ours.count == emacs.count) |
| 116 | 117 | for (o, e) in zip(ours, emacs) { |
| 117 | 118 | #expect(o.header == e.header) |
| 118 | #expect(describe(o.items) == describe(e.items), "\(e.header) today \(Days.date(today))") | |
| 119 | #expect(describe(o.items, today: today) == describe(e.items), "\(e.header) today \(Days.date(today))") | |
| 119 | 120 | } |
| 120 | 121 | } |
| 121 | 122 | |
| @@ -125,13 +126,14 @@ enum AgendaOracle { | ||
| 125 | 126 | } |
| 126 | 127 | } |
| 127 | 128 | |
| 128 | static func describe(_ items: [AgendaItem]) -> [String] { | |
| 129 | static func describe(_ items: [AgendaItem], today: Int = 0) -> [String] { | |
| 129 | 130 | items.map { item in |
| 131 | let line = item.habit.map { Habit.insertGraph($0.graph(today: today), into: item.line) } ?? item.line | |
| 130 | 132 | let type: String = switch item.kind { |
| 131 | 133 | case .timeGrid, .currentTime: "-" |
| 132 | 134 | default: item.kind.rawValue |
| 133 | 135 | } |
| 134 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 136 | return "\(normalize(line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 135 | 137 | } |
| 136 | 138 | } |
| 137 | 139 | |
Tests/OrgCoreTests/HabitTests.swift added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct HabitTests { | |
| 6 | static let habits = """ | |
| 7 | * TODO Exercise | |
| 8 | SCHEDULED: <2026-10-05 Mon .+1d> | |
| 9 | :PROPERTIES: | |
| 10 | :STYLE: habit | |
| 11 | :END: | |
| 12 | :LOGBOOK: | |
| 13 | - State "DONE" from "TODO" [2026-10-04 Sun 08:00] | |
| 14 | - State "DONE" from "TODO" [2026-10-02 Fri 08:10] | |
| 15 | - State "DONE" from "TODO" [2026-09-28 Mon 07:00] | |
| 16 | - State "DONE" from "TODO" [2026-09-10 Thu 07:00] | |
| 17 | :END: | |
| 18 | * TODO Water plants | |
| 19 | SCHEDULED: <2026-10-01 Thu .+2d/4d> | |
| 20 | :PROPERTIES: | |
| 21 | :STYLE: habit | |
| 22 | :END: | |
| 23 | - State "DONE" from "TODO" [2026-09-29 Tue 18:00] | |
| 24 | - State "DONE" from "TODO" [2026-09-24 Thu 18:00] | |
| 25 | - State "KILL" from "TODO" [2026-09-20 Sun 18:00] | |
| 26 | * TODO Weekly review | |
| 27 | SCHEDULED: <2026-10-08 Thu +1w> | |
| 28 | :PROPERTIES: | |
| 29 | :STYLE: habit | |
| 30 | :END: | |
| 31 | :LOGBOOK: | |
| 32 | - State "DONE" from "TODO" [2026-10-01 Thu 17:00] | |
| 33 | - State "DONE" from "TODO" [2026-09-24 Thu 17:00] | |
| 34 | - State "DONE" from "TODO" [2026-09-16 Wed 17:00] | |
| 35 | :END: | |
| 36 | * TODO Stretch | |
| 37 | SCHEDULED: <2026-10-02 Fri 07:30 ++1d> | |
| 38 | :PROPERTIES: | |
| 39 | :STYLE: habit | |
| 40 | :END: | |
| 41 | :LOGBOOK: | |
| 42 | - State "DONE" from "TODO" [2026-10-01 Thu 07:40] | |
| 43 | - State "DONE" from "TODO" [2026-09-29 Tue 07:35] | |
| 44 | :END: | |
| 45 | * TODO Read | |
| 46 | SCHEDULED: <2026-10-03 Sat ++3d/5d> | |
| 47 | :PROPERTIES: | |
| 48 | :STYLE: habit | |
| 49 | :END: | |
| 50 | - CLOSING NOTE [2026-09-30 Wed 21:00] \\\\ | |
| 51 | finished a chapter | |
| 52 | * TODO New habit | |
| 53 | SCHEDULED: <2026-10-06 Tue .+1d> | |
| 54 | :PROPERTIES: | |
| 55 | :STYLE: habit | |
| 56 | :END: | |
| 57 | * DONE Done habit | |
| 58 | SCHEDULED: <2026-10-05 Mon .+1d> | |
| 59 | :PROPERTIES: | |
| 60 | :STYLE: habit | |
| 61 | :END: | |
| 62 | * TODO Plain task | |
| 63 | SCHEDULED: <2026-10-05 Mon> | |
| 64 | * TODO Late task | |
| 65 | SCHEDULED: <2026-10-03 Sat> | |
| 66 | """ | |
| 67 | ||
| 68 | static let broken = """ | |
| 69 | * TODO No repeat | |
| 70 | SCHEDULED: <2026-10-05 Mon> | |
| 71 | :PROPERTIES: | |
| 72 | :STYLE: habit | |
| 73 | :END: | |
| 74 | * TODO Short deadline | |
| 75 | SCHEDULED: <2026-10-05 Mon .+3d/2d> | |
| 76 | :PROPERTIES: | |
| 77 | :STYLE: habit | |
| 78 | :END: | |
| 79 | """ | |
| 80 | ||
| 81 | struct Graph: Codable, Equatable { | |
| 82 | var pos: Int | |
| 83 | var parse: String? | |
| 84 | var glyphs: String? | |
| 85 | var faces: [String]? | |
| 86 | } | |
| 87 | ||
| 88 | /// `org-habit-parse-todo` and `org-habit-build-graph` for each heading, as Emacs gives them. | |
| 89 | static func emacs(_ text: String, today: Int) throws -> [Graph] { | |
| 90 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-habit-\(UUID().uuidString)") | |
| 91 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | |
| 92 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 93 | let file = folder.appendingPathComponent("h.org") | |
| 94 | try text.write(to: file, atomically: true, encoding: .utf8) | |
| 95 | let output = folder.appendingPathComponent("out.json") | |
| 96 | let script = folder.appendingPathComponent("habit.el") | |
| 97 | let words = AgendaOracle.keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ") | |
| 98 | try #""" | |
| 99 | ;;; -*- lexical-binding: t -*- | |
| 100 | (require 'org) | |
| 101 | (require 'org-habit) | |
| 102 | (require 'json) | |
| 103 | (setq org-todo-keywords '((sequence \#(words)))) | |
| 104 | \#(frozenClock((today - 719163) * 86400 + 9 * 3600, """ | |
| 105 | (with-current-buffer (find-file-noselect "\(file.path)") | |
| 106 | (let (result (moment (current-time))) | |
| 107 | (org-map-entries | |
| 108 | (lambda () | |
| 109 | (let* ((habit (condition-case nil (org-habit-parse-todo) (error nil))) | |
| 110 | (graph (and habit (org-habit-build-graph habit | |
| 111 | (time-subtract moment (days-to-time org-habit-preceding-days)) | |
| 112 | moment | |
| 113 | (time-add moment (days-to-time org-habit-following-days)))))) | |
| 114 | (push `((pos . ,(point)) | |
| 115 | (parse . ,(and habit (prin1-to-string habit))) | |
| 116 | (glyphs . ,graph) | |
| 117 | (faces . ,(and graph (vconcat (mapcar (lambda (i) (symbol-name (get-text-property i 'face graph))) | |
| 118 | (number-sequence 0 (- (length graph) 2))))))) | |
| 119 | result)))) | |
| 120 | (let ((coding-system-for-write 'utf-8-unix)) | |
| 121 | (with-temp-file (getenv "ORACLE_OUTPUT") | |
| 122 | (insert (json-encode (vconcat (nreverse result)))))))) | |
| 123 | """)) | |
| 124 | """#.write(to: script, atomically: true, encoding: .utf8) | |
| 125 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"]) | |
| 126 | guard let data = try? Data(contentsOf: output) else { throw AgendaOracle.OracleError(log: log) } | |
| 127 | return try JSONDecoder().decode([Graph].self, from: data).map { graph in | |
| 128 | var graph = graph | |
| 129 | graph.pos = EmacsOracle.offset(graph.pos, in: text) | |
| 130 | return graph | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | static func ours(_ text: String, today: Int) -> [Graph] { | |
| 135 | let defaults = OrgSettings.default.withTodoKeywords(AgendaOracle.keywords)! | |
| 136 | let source = AgendaSource(path: "h.org", text: text, defaults: defaults) | |
| 137 | return source.headings.map { heading in | |
| 138 | guard let habit = heading.habit else { return Graph(pos: heading.start) } | |
| 139 | let deadline = habit.deadlineDay.map(String.init) ?? "nil" | |
| 140 | let repeat_ = habit.deadlineRepeatDays.map(String.init) ?? "nil" | |
| 141 | let done = habit.doneDates.isEmpty ? "nil" : "(" + habit.doneDates.map(String.init).joined(separator: " ") + ")" | |
| 142 | let parse = "(\(habit.scheduled) \(habit.scheduledRepeat) \(deadline) \(repeat_) \(done) \"\(habit.repeatType)\")" | |
| 143 | let cells = habit.graph(today: today) | |
| 144 | let faces = cells.map { cell -> String in | |
| 145 | let base = cell.face.rawValue.replacingOccurrences(of: "Future", with: "") | |
| 146 | return "org-habit-\(base)-" + (cell.face.rawValue.hasSuffix("Future") ? "future-face" : "face") | |
| 147 | } | |
| 148 | return Graph(pos: heading.start, parse: parse, glyphs: String(cells.map(\.glyph)) + " ", faces: faces) | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 153 | func graphsMatchEmacs() throws { | |
| 154 | for (y, m, d) in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 12), (2026, 9, 20)] { | |
| 155 | let today = Days.absolute(year: y, month: m, day: d) | |
| 156 | for text in [Self.habits, Self.broken] { | |
| 157 | #expect(Self.ours(text, today: today) == (try Self.emacs(text, today: today)), "\(y)-\(m)-\(d)") | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 163 | func agendaMatchesEmacs() throws { | |
| 164 | let files = [("habits.org", Self.habits)] | |
| 165 | for (y, m, d) in [(2026, 10, 5), (2026, 10, 3), (2026, 10, 9)] { | |
| 166 | let today = Days.absolute(year: y, month: m, day: d) | |
| 167 | try AgendaOracle.compare(files: files, start: today - 1, span: 3, today: today) | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | @Test func urgency() { | |
| 172 | let habit = Habit.parse(entry: "", scheduled: "2026-10-01 Thu .+2d/4d", doneKeywords: ["DONE"])! | |
| 173 | let oct1 = Days.absolute(year: 2026, month: 10, day: 1) | |
| 174 | #expect(habit.deadline == oct1 + 2) | |
| 175 | #expect(habit.urgency(today: oct1) == 1000 - 10) | |
| 176 | #expect(habit.urgency(today: oct1 + 2) == 1000 + 20 + 50 + 100) | |
| 177 | #expect(habit.urgency(today: oct1 + 4) == 1000 + 40 + 300) | |
| 178 | } | |
| 179 | } | |
Tests/OrgCoreTests/ReadDateTests.swift +4
| @@ -9,9 +9,13 @@ func frozenClock(_ seconds: Int, _ body: String) -> String { | ||
| 9 | 9 | (odt (symbol-function 'decode-time)) |
| 10 | 10 | (oft (symbol-function 'format-time-string)) |
| 11 | 11 | (otl (symbol-function 'time-less-p)) |
| 12 | (ots (symbol-function 'time-subtract)) | |
| 13 | (ota (symbol-function 'time-add)) | |
| 12 | 14 | ((symbol-function 'current-time) (lambda () frozen)) |
| 13 | 15 | ((symbol-function 'org-today) (lambda () (time-to-days frozen))) |
| 14 | 16 | ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen)))) |
| 17 | ((symbol-function 'time-subtract) (lambda (a b) (funcall ots (or a frozen) (or b frozen)))) | |
| 18 | ((symbol-function 'time-add) (lambda (a b) (funcall ota (or a frozen) (or b frozen)))) | |
| 15 | 19 | ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form))) |
| 16 | 20 | ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone)))) |
| 17 | 21 | \(body)) |
docs/plans/2026-10-05-habits.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Habits Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** org-habit consistency graphs in the agenda. | |
| 6 | ||
| 7 | **Architecture:** `Habit` ports `org-habit-parse-todo`, `org-habit-get-urgency`, `org-habit-get-faces` and `org-habit-build-graph` with org-habit's defaults (21 days before, 7 after, `org-log-states-order-reversed` t). `AgendaSource` reads it for entries with `STYLE: habit`. `Agenda.list` applies org-habit's rules: habits only on today's agenda and not when done, always with their time, no leader, habit urgency, sorted after other entries (`habit-down`). The agenda view draws the graph as colored day cells with `*` for done days and `!` for today. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 6". | |
| 12 | ||
| 13 | --- | |
| 14 | ||
| 15 | - [x] `Habit`; graph and parse oracle; agenda oracle with org-habit loaded (`frozenClock` also covers `time-subtract`/`time-add` with nil). | |
| 16 | - [x] Agenda rows draw the graph. | |
| 17 | - [x] Commit "Habit graphs". | |