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