krz/orgstar

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

Sources/OrgCore/Commands/ClockReport.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgCore/Commands/ClockReport.swift history · blame · raw

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}