Sources/Orgstar/ClockViews.swift
141 lines · 5576 bytes
1import AppKit
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Elapsed time of the running clock, `H:MM`.
7func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
8 let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60))
9 return String(format: "%d:%02d", minutes / 60, minutes % 60)
10}
11
12/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
13@MainActor
14final class ClockStatusItem: NSObject {
15 private var item: NSStatusItem?
16 private var timer: Timer?
17 private var running: ClockModel.Running?
18 private let run: (String) -> Void
19
20 init(run: @escaping (String) -> Void) {
21 self.run = run
22 }
23
24 func update(_ running: ClockModel.Running?) {
25 self.running = running
26 guard let running else {
27 timer?.invalidate()
28 timer = nil
29 if let item { NSStatusBar.system.removeStatusItem(item) }
30 item = nil
31 return
32 }
33 if item == nil {
34 item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
35 timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
36 MainActor.assumeIsolated { self?.refresh() }
37 }
38 }
39 let menu = NSMenu()
40 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
41 menu.addItem(.separator())
42 for (title, id) in [("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"), ("Clock Report", "app.clock.report")] {
43 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "")
44 entry.target = self
45 entry.representedObject = id
46 menu.addItem(entry)
47 }
48 item?.menu = menu
49 refresh()
50 }
51
52 private func refresh() {
53 guard let running else { return }
54 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
55 }
56
57 @objc private func choose(_ sender: NSMenuItem) {
58 if let id = sender.representedObject as? String {
59 NSApp.activate()
60 run(id)
61 }
62 }
63}
64
65/// Time per day and heading, as your `tr/generate-report` writes it, for chosen files and
66/// dates.
67struct ClockReportView: View {
68 let workspace: WorkspaceModel
69 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
70 @State private var end = Date()
71 @State private var limit = false
72 @State private var selected: Set<String> = []
73 @State private var report = ""
74
75 private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } }
76
77 var body: some View {
78 HSplitView {
79 List(candidates, selection: $selected) { file in
80 Text(file.relativePath).tag(file.path)
81 }
82 .frame(minWidth: 180, idealWidth: 220, maxWidth: 320)
83 VStack(alignment: .leading, spacing: 8) {
84 HStack {
85 Toggle("From", isOn: $limit)
86 DatePicker("", selection: $start, displayedComponents: .date).labelsHidden().disabled(!limit)
87 Text("to")
88 DatePicker("", selection: $end, displayedComponents: .date).labelsHidden().disabled(!limit)
89 Spacer()
90 Button("Copy") {
91 NSPasteboard.general.clearContents()
92 NSPasteboard.general.setString(report, forType: .string)
93 }
94 Button("Save…", action: saveReport)
95 }
96 ScrollView([.horizontal, .vertical]) {
97 Text(report.isEmpty ? "Choose files with clocked time." : report)
98 .font(.body.monospaced())
99 .textSelection(.enabled)
100 .fixedSize()
101 .padding(4)
102 }
103 .defaultScrollAnchor(.topLeading)
104 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
105 }
106 .padding(12)
107 }
108 .navigationTitle("Clock Report")
109 .onAppear {
110 if selected.isEmpty {
111 selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path))
112 }
113 compute()
114 }
115 .onChange(of: selected) { compute() }
116 .onChange(of: start) { compute() }
117 .onChange(of: end) { compute() }
118 .onChange(of: limit) { compute() }
119 }
120
121 private func day(_ date: Date) -> String {
122 let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
123 return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
124 }
125
126 private func compute() {
127 var entries: [ClockReport.Entry] = []
128 for path in selected.sorted() {
129 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
130 entries += ClockReport.entries(text, settings: workspace.settings.org)
131 }
132 report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil)
133 }
134
135 private func saveReport() {
136 let panel = NSSavePanel()
137 panel.nameFieldStringValue = "time-report.org"
138 guard panel.runModal() == .OK, let url = panel.url else { return }
139 try? report.write(to: url, atomically: true, encoding: .utf8)
140 }
141}