krz/orgstar

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

Sources/OrgCore/Commands/ClockReport.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgCore/Commands/ClockReport.swift history · blame · raw

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}