krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit c48fbeb058

c48fbeb058aeeb1422ef48a2b7105313923f6b4e

parent: d3f871e3e7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 08:08 UTC

Habit graphs

Habit ports org-habit-parse-todo, get-urgency and build-graph. The
agenda follows org-habit's rules for STYLE: habit entries and draws
the consistency graph.

Layout: unified · split

Sources/OrgCore/Agenda/Agenda.swift +15 −7
@@ -37,6 +37,8 @@ public struct AgendaItem: Sendable, Equatable {
3737 public let urgency: Int
3838 /// `APPT_WARNTIME`: minutes of warning before the entry's time.
3939 public let warntime: Int?
40 /// The habit whose consistency graph goes on the line (scheduled habits).
41 public let habit: Habit?
4042 /// The line as org prints it, with one space before the tags.
4143 public let line: String
4244}
@@ -144,11 +146,15 @@ public enum Agenda {
144146 continue
145147 }
146148 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
149154 items.append(format(
150155 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 }
152158 ))
153159 }
154160 return items
@@ -204,7 +210,8 @@ public enum Agenda {
204210 let heading = source.headings[stamp.heading]
205211 items.append(format(
206212 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 }
208215 ))
209216 }
210217 return items
@@ -240,7 +247,7 @@ public enum Agenda {
240247 static func format(
241248 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
242249 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true,
243 urgency: (Int) -> Int
250 habit: Habit? = nil, urgency: (Int) -> Int
244251 ) -> AgendaItem {
245252 var txt = heading.head.trimmingCharacters(in: .whitespaces)
246253 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
@@ -279,7 +286,7 @@ public enum Agenda {
279286 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category,
280287 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
281288 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,
283290 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
284291 )
285292 }
@@ -394,7 +401,7 @@ public enum Agenda {
394401 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
395402 return AgendaItem(
396403 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,
398405 line: " " + pad("", 12) + pad(timed.time, 12) + txt
399406 )
400407 }
@@ -410,6 +417,7 @@ public enum Agenda {
410417 /// `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, stably.
411418 static func sort(_ items: [AgendaItem]) -> [AgendaItem] {
412419 items.enumerated().sorted { a, b in
420 if (a.element.habit == nil) != (b.element.habit == nil) { return a.element.habit == nil }
413421 let ta = a.element.timeOfDay ?? 9901
414422 let tb = b.element.timeOfDay ?? 9901
415423 if ta != tb { return ta < tb }
Sources/OrgCore/Agenda/AgendaSource.swift +12 −1
@@ -35,6 +35,10 @@ public struct AgendaSource: Sendable {
3535 /// The planning timestamp without brackets, and the position of its `<`.
3636 let scheduled: (raw: String, offset: Int)?
3737 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?
3842 }
3943
4044 struct Stamp: Sendable {
@@ -176,13 +180,20 @@ public struct AgendaSource: Sendable {
176180 }
177181 }
178182 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 }
179190 headings.append(Heading(
180191 start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index],
181192 title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags,
182193 properties: properties, planning: planningValues,
183194 firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") },
184195 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
186197 ))
187198 }
188199 self.headings = headings
Sources/OrgCore/Agenda/Habit.swift added +186
@@ -0,0 +1,186 @@
1import 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.
5public 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 {
7979 if isAgenda {
8080 ForEach(agenda.days, id: \.day) { day in
8181 Section {
82 rows(day.items, prefix: "\(day.day)")
82 rows(day.items, prefix: "\(day.day)", day: day.day)
8383 } header: {
8484 Text(day.header)
8585 .font(.headline)
@@ -172,9 +172,9 @@ struct AgendaView: View {
172172 }
173173
174174 @ViewBuilder
175 private func rows(_ items: [AgendaItem], prefix: String) -> some View {
175 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
176176 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
177 AgendaRow(item: item)
177 AgendaRow(item: item, day: day)
178178 .tag("\(prefix):\(index)")
179179 .selectionDisabled(item.path == nil)
180180 }
@@ -316,6 +316,8 @@ struct AgendaView: View {
316316
317317struct AgendaRow: View {
318318 let item: AgendaItem
319 /// The agenda day the row is on, for habit graphs.
320 var day: Int?
319321
320322 var body: some View {
321323 switch item.kind {
@@ -356,6 +358,9 @@ struct AgendaRow: View {
356358 title
357359 .lineLimit(1)
358360 Spacer(minLength: 8)
361 if let habit = item.habit, let day {
362 HabitGraph(cells: habit.graph(today: day))
363 }
359364 if !item.tags.isEmpty {
360365 Text(":" + item.tags.joined(separator: ":") + ":")
361366 .font(.caption)
@@ -386,3 +391,40 @@ struct AgendaRow: View {
386391 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
387392 }
388393}
394
395/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
396struct 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 {
2929 ;;; -*- lexical-binding: t -*-
3030 (require 'org)
3131 (require 'org-agenda)
32 (require 'org-habit)
3233 (require 'json)
3334 (setq org-todo-keywords '((sequence \#(words)))
3435 org-agenda-files '(\#(fileList)))
@@ -115,7 +116,7 @@ enum AgendaOracle {
115116 #expect(ours.count == emacs.count)
116117 for (o, e) in zip(ours, emacs) {
117118 #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))")
119120 }
120121 }
121122
@@ -125,13 +126,14 @@ enum AgendaOracle {
125126 }
126127 }
127128
128 static func describe(_ items: [AgendaItem]) -> [String] {
129 static func describe(_ items: [AgendaItem], today: Int = 0) -> [String] {
129130 items.map { item in
131 let line = item.habit.map { Habit.insertGraph($0.graph(today: today), into: item.line) } ?? item.line
130132 let type: String = switch item.kind {
131133 case .timeGrid, .currentTime: "-"
132134 default: item.kind.rawValue
133135 }
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) ?? "-")"
135137 }
136138 }
137139
Tests/OrgCoreTests/HabitTests.swift added +179
@@ -0,0 +1,179 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct 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 {
99 (odt (symbol-function 'decode-time))
1010 (oft (symbol-function 'format-time-string))
1111 (otl (symbol-function 'time-less-p))
12 (ots (symbol-function 'time-subtract))
13 (ota (symbol-function 'time-add))
1214 ((symbol-function 'current-time) (lambda () frozen))
1315 ((symbol-function 'org-today) (lambda () (time-to-days frozen)))
1416 ((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))))
1519 ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form)))
1620 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone))))
1721 \(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".