Class: HarvestWorklog::ReconcileCLI
- Inherits:
-
Object
- Object
- HarvestWorklog::ReconcileCLI
- Defined in:
- lib/harvest_worklog/reconcile_cli.rb
Class Method Summary collapse
- .default_log_path ⇒ Object
- .duration(milliseconds) ⇒ Object
- .local_intervals(log_path, date:, project:) ⇒ Object
- .merged_duration(intervals) ⇒ Object
- .option_parser(options) ⇒ Object
- .print_reconciliation(output, date:, local_project:, intervals:, harvest_entries:) ⇒ Object
- .run(arguments, output: $stdout, error: $stderr, client: nil, log_path: default_log_path, today: Date.today) ⇒ Object
- .signed_duration(milliseconds) ⇒ Object
- .validate!(dates, options) ⇒ Object
Class Method Details
.default_log_path ⇒ Object
52 53 54 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 52 def self.default_log_path File.join(Dir.home, ".omp", "project-time", "time-log.json") end |
.duration(milliseconds) ⇒ Object
111 112 113 114 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 111 def self.duration(milliseconds) seconds = (milliseconds / 1000.0).round "%dh %dm %ds" % [seconds / 3600, seconds / 60 % 60, seconds % 60] end |
.local_intervals(log_path, date:, project:) ⇒ Object
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 56 def self.local_intervals(log_path, date:, project:) state = JSON.parse(File.read(log_path)) entries = state.is_a?(Hash) ? state["entries"] : nil raise Error, "OMP Project Time log is missing an entries array" unless entries.is_a?(Array) day_start = Time.local(date.year, date.month, date.day).to_f * 1000 next_date = date + 1 day_end = Time.local(next_date.year, next_date.month, next_date.day).to_f * 1000 entries.filter_map do |entry| next unless entry.is_a?(Hash) && entry["sourceKind"] == "human_active" && entry["project"] == project start_at = entry["startAtMs"] end_at = entry["endAtMs"] raise Error, "OMP Project Time log contains an invalid session interval" unless start_at.is_a?(Numeric) && end_at.is_a?(Numeric) && start_at < end_at start_at = [start_at, day_start].max end_at = [end_at, day_end].min [start_at, end_at] if start_at < end_at end end |
.merged_duration(intervals) ⇒ Object
93 94 95 96 97 98 99 100 101 102 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 93 def self.merged_duration(intervals) merged = intervals.sort_by(&:first).each_with_object([]) do |(start_at, end_at), result| if result.empty? || start_at > result.last.last result << [start_at, end_at] else result.last[1] = [result.last[1], end_at].max end end merged.sum { |start_at, end_at| end_at - start_at } end |
.option_parser(options) ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 31 def self.option_parser() OptionParser.new do |opts| opts. = "Usage: harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]" opts.on("--project NAME", "Filter local OMP Project Time project name") { |value| [:project] = value } opts.on("--harvest-project NAME", "Filter manual Harvest project name") { |value| [:harvest_project] = value } opts.on("--task NAME", "Filter manual Harvest task name") { |value| [:task] = value } opts.on("-h", "--help", "Show this help") do puts opts exit 0 end end end |
.print_reconciliation(output, date:, local_project:, intervals:, harvest_entries:) ⇒ Object
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 77 def self.print_reconciliation(output, date:, local_project:, intervals:, harvest_entries:) raw = intervals.sum { |start_at, end_at| end_at - start_at } union = merged_duration(intervals) harvest = AggregateCLI.total_hours(harvest_entries) * 3_600_000 output.puts "#{date.iso8601} reconciliation" output.puts "Manual Harvest: #{duration(harvest)} (#{harvest_entries.length} #{AggregateCLI.entry_label(harvest_entries.length)})" output.puts "Local OMP Project Time #{local_project}:" output.puts " Raw intervals: #{duration(raw)}" output.puts " Non-overlapping union: #{duration(union)}" output.puts " Concurrent overlap: #{duration(raw - union)}" output.puts "Harvest minus local raw: #{signed_duration(harvest - raw)}" output.puts "Harvest minus local union: #{signed_duration(harvest - union)}" output.puts "Verdict: Manual Harvest is the benchmark. This read-only comparison creates or changes no entries." end |
.run(arguments, output: $stdout, error: $stderr, client: nil, log_path: default_log_path, today: Date.today) ⇒ Object
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 5 def self.run(arguments, output: $stdout, error: $stderr, client: nil, log_path: default_log_path, today: Date.today) = {} parser = option_parser() dates = parser.parse(arguments) validate!(dates, ) date = TimesheetCLI.resolve_date(dates.first, today:) client ||= Marlens::HarvestApiV2::Client.from_environment user_id = TimesheetCLI.current_user_id(client) harvest_entries = AggregateCLI.fetch_entries(client, from: date, to: date, user_id:).select do |entry| AggregateCLI.matches?(entry, project: [:harvest_project], task: [:task]) end print_reconciliation( output, date:, local_project: [:project], intervals: local_intervals(log_path, date:, project: [:project]), harvest_entries: ) 0 rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error, Errno::ENOENT, JSON::ParserError => e error.puts "Error: #{e.}" error.puts parser if parser 1 end |
.signed_duration(milliseconds) ⇒ Object
104 105 106 107 108 109 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 104 def self.signed_duration(milliseconds) seconds = (milliseconds / 1000.0).round return duration(0) if seconds.zero? "#{seconds.negative? ? '-' : '+'}#{duration(seconds.abs * 1000)}" end |
.validate!(dates, options) ⇒ Object
44 45 46 47 48 49 50 |
# File 'lib/harvest_worklog/reconcile_cli.rb', line 44 def self.validate!(dates, ) raise Error, "DATE is required" unless dates.length == 1 %i[project harvest_project].each do |name| raise Error, "--#{name.to_s.tr('_', '-')} is required" unless [name]&.strip&.length&.positive? end raise Error, "--task must not be blank" if [:task]&.strip&.empty? end |