Sources/OrgCore/Commands/ClockReport.swift
102 lines · 4829 bytes
1import Foundation
2
3/// Time spent per day and heading, from finished `CLOCK:` lines.
4public enum ClockReport {
5 public struct Entry: Sendable, Equatable {
6 /// `YYYY-MM-DD` of the clock's start.
7 public let date: String
8 /// The heading's title, without keyword, priority, tags or COMMENT.
9 public let heading: String
10 public let minutes: Int
11 }
12
13 static let clockLine = try! NSRegularExpression(pattern:
14 "CLOCK: \\[([0-9]{4}-[0-9]{2}-[0-9]{2}) [A-Za-z]{3} [0-9:]{5}\\]--\\[([0-9]{4}-[0-9]{2}-[0-9]{2}) [A-Za-z]{3} ([0-9:]{5})\\] => +([0-9]+):([0-9]+)"
15 )
16
17 /// The finished clocks in `text`, in order.
18 public static func entries(_ text: String, settings: OrgSettings = .default) -> [Entry] {
19 let tree = OrgParser.parse(text, defaults: settings)
20 let model = DocumentModel(tree: tree)
21 let starts = model.headings.map(\.headingRange.lowerBound)
22 let ns = text as NSString
23 var result: [Entry] = []
24 for m in clockLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
25 var low = 0
26 var high = starts.count
27 while low < high {
28 let mid = (low + high) / 2
29 if starts[mid] <= m.range.location { low = mid + 1 } else { high = mid }
30 }
31 guard low > 0 else { continue }
32 var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces)
33 if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) }
34 let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))!
35 result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes))
36 }
37 return result
38 }
39
40 static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) }
41
42 /// Emacs `format "%-Ns"` pads by characters.
43 static func pad(_ s: String, _ width: Int) -> String {
44 s.count >= width ? s : s + String(repeating: " ", count: width - s.count)
45 }
46
47 static func padLeft(_ s: String, _ width: Int) -> String {
48 s.count >= width ? s : String(repeating: " ", count: width - s.count) + s
49 }
50
51 /// `%Y-W%V`.
52 static func isoWeek(_ date: String) -> String {
53 let parts = date.split(separator: "-").compactMap { Int($0) }
54 guard parts.count == 3 else { return date }
55 let day = Days.absolute(year: parts[0], month: parts[1], day: parts[2])
56 let thursday = day - (Days.weekday(day) + 6) % 7 + 3
57 return String(format: "%04d-W%02d", Days.date(thursday).year, Days.isoWeek(day))
58 }
59
60 /// The time report your Doom configuration writes (`tr/generate-report`): minutes per
61 /// date and heading between `start` and `end` (inclusive, `YYYY-MM-DD`), with a total
62 /// after each ISO week.
63 public static func timeReport(_ entries: [Entry], start: String? = nil, end: String? = nil) -> String {
64 var totals: [String: [String: Int]] = [:]
65 for entry in entries {
66 if let start, entry.date < start { continue }
67 if let end, entry.date > end { continue }
68 totals[entry.date, default: [:]][entry.heading, default: 0] += entry.minutes
69 }
70 var keys: [(date: String, heading: String)] = []
71 for (date, headings) in totals {
72 for heading in headings.keys { keys.append((date, heading)) }
73 }
74 keys.sort { a, b in a.date != b.date ? a.date < b.date : a.heading < b.heading }
75 let dashes14 = String(repeating: "-", count: 14)
76 let dashes47 = String(repeating: "-", count: 47)
77 let dashes7 = String(repeating: "-", count: 7)
78 let rule: String = "|" + dashes14 + "+" + dashes47 + "+" + dashes7 + "|\n"
79 func row(_ a: String, _ b: String, _ c: String) -> String { "| \(pad(a, 12)) | \(pad(b, 45)) | \(padLeft(c, 5)) |\n" }
80 var out = "#+title: Time Report\n\n" + row("Date", "Code", "Hours") + rule
81 var currentWeek: String?
82 var weekMinutes = 0
83 for (date, heading) in keys {
84 let minutes = totals[date]![heading]!
85 let week = isoWeek(date)
86 if let current = currentWeek, week != current {
87 out += rule + row(current, "WEEK TOTAL", hours(weekMinutes)) + rule
88 weekMinutes = 0
89 }
90 if week != currentWeek {
91 currentWeek = week
92 weekMinutes = 0
93 }
94 weekMinutes += minutes
95 out += row(date, heading, hours(minutes))
96 }
97 if let current = currentWeek {
98 out += rule + row(current, "WEEK TOTAL", hours(weekMinutes))
99 }
100 return out + "\n"
101 }
102}