harvest-worklog 0.10.6 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e46ca248b7d1ec0b17de75c726874d41c653182f7ca51437e3ffff95210869f
4
- data.tar.gz: db4d6e6e123df77a7ba7cc0996a356d79140402a23d2dca0598665e29b5a1a99
3
+ metadata.gz: 65c8eff780c91f0fc513e021af972f0a2b72a51a0a0b5d80a1854c112816a8e7
4
+ data.tar.gz: 708ec5a2a2b7bda162623c2694eca533431dd7fe0592560bc97300c4614fa59d
5
5
  SHA512:
6
- metadata.gz: 6022e40a5eed269ac42eb089b9fc858b616b7fb7515a009489830600b9b39ad2fdaf138b6fb3ea36df17a7bac855e502d9dcae6aa51eca7a5033ff4a9bb88e90
7
- data.tar.gz: 4abaec9e28c59ef818e7fd3c6471d9fa4d8ea4b067adf43925fcca43ea835e255164836bcc8069af23d9b18e9b5c071bac7f1de2e044907ed117b6cc62355969
6
+ metadata.gz: 745e280ee770aa2ade4d0ae89cdb4c32aa656fe71290dcf7298c9521cc09ba59dda948d48449008cce8a0029e701680039f8d8238114b8fe9eea350a54e00698
7
+ data.tar.gz: c07e15b6ffb42f580a9591726af256e27f06624bad2e8c8ed4c5ff62d6860a37b26787f6943e35b8cd1bed9a09f20a79f8e63b4b229b678093eb90e889d36108
data/README.md CHANGED
@@ -26,6 +26,7 @@ harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes
26
26
  harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
27
27
  harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
28
28
  harvest-worklog timesheet DATE --project NAME [--task NAME]
29
+ harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
29
30
  ```
30
31
 
31
32
  ### Time off
@@ -93,25 +94,26 @@ Configure `projectTimeMappings` with the recorded OMP Project Time project name
93
94
  }
94
95
  ```
95
96
 
96
- `harvest_preview_project_time_entries` reads the configured time log, splits sessions across local dates, generates descriptions from the project and repository, and checks Harvest for existing or locked entries without writing. `harvest_record_project_time_entries` performs the same preflight then creates only new entries; OMP treats it as a write requiring approval. Unmapped sessions are reported and never written.
97
+ `harvest_preview_project_time_entries` reads only `human_active` sessions from the configured time log, splits them across local dates, reports that source policy, generates descriptions from the project and repository, and checks Harvest for existing or locked entries without writing. `harvest_record_project_time_entries` performs the same preflight then creates only new entries; OMP treats it as a write requiring approval. Unmapped sessions are reported and never written.
97
98
 
98
99
  ### Activity transforms
99
100
 
100
- `harvest_preview_project_time_transforms` emits deterministic JSON for local raw intervals. It accepts an inclusive date range and optional exact `repositoryId`, `project`, and `sourceKind` filters; each matching interval is split by local date and grouped by activity, with missing labels reported as `unlabelled`. Set `applyMappings` to include configured Harvest project/task mappings. The output reports `groups`, proposed mapped `entries`, and any `unmapped` or `excluded` rows.
101
+ `harvest_preview_project_time_transforms` emits deterministic JSON for local raw intervals. It accepts an inclusive date range and optional exact `repositoryId`, `project`, and `sourceKind` filters; each matching interval is split by local date and grouped by activity, with missing labels reported as `unlabelled`. It defaults to `human_active` and returns the effective top-level `sourceKind`; selecting `agent_turn_elapsed` requires an explicit source-kind request. Set `applyMappings` to include configured Harvest project/task mappings. The output reports `groups`, proposed mapped `entries`, and any `unmapped` or `excluded` rows.
101
102
 
102
103
  `harvest_record_project_time_transforms` is a separate approval-gated write step. It always applies configured mappings and records the reviewed activity entries through the existing locked and duplicate preflight. Different activity labels may share a date/project/task; rerunning the same activity label is skipped, as are locked or unrelated existing entries.
103
104
 
104
- ## Reconciling WRAP hours with manual entries
105
+ ## Daily reconciliation
105
106
 
106
- Use a closed date range and record the expected OMP Project Time and manually entered hours for each day. Preview OMP entries first, then compare the final Harvest total for each day:
107
+ `reconcile` compares one local Project Time project against the manual Harvest total for one date. It reads only local `human_active` intervals, reports the raw duration, non-overlapping union, concurrent overlap, and both Harvest-minus-local deltas. It never creates or changes Harvest entries: manual Harvest is the benchmark.
107
108
 
108
109
  ```bash
109
- harvest-worklog aggregate FROM TO \
110
- --project WRAP \
110
+ harvest-worklog reconcile 2026-07-21 \
111
+ --project wrap \
112
+ --harvest-project WRAP \
111
113
  --task Programming
112
114
  ```
113
115
 
114
- OMP-created entries use an `OMP Project Time:` note; use a `Manual reconciliation:` note for manual entries while testing. Inspect the Harvest entry details to distinguish their source—the aggregate intentionally combines all matching entries.
116
+ Empty local or Harvest data is reported as zero, so this command is suitable for daily capture checks while manual tracking remains authoritative. [#51](https://github.com/klondikemarlen/harvest-worklog/issues/51) separately ensures ordinary automatic exports use only the same `human_active` evidence.
115
117
 
116
118
  The standard work-entry importer allows only one entry for a date, project, and task. If a manual `WRAP / Programming` entry exists for a date, importing ordinary OMP Project Time for that same mapping skips it rather than adding hours. Activity transforms are the exception: distinct activity labels can create separate entries while the same label, locked entries, and unrelated manual entries are still skipped. Reconcile ordinary entries as either OMP or manual hours, not their sum.
117
119
 
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HarvestWorklog
4
+ class ReconcileCLI
5
+ def self.run(arguments, output: $stdout, error: $stderr, client: nil, log_path: default_log_path, today: Date.today)
6
+ options = {}
7
+ parser = option_parser(options)
8
+ dates = parser.parse(arguments)
9
+ validate!(dates, options)
10
+
11
+ date = TimesheetCLI.resolve_date(dates.first, today:)
12
+ client ||= Marlens::HarvestApiV2::Client.from_environment
13
+ user_id = TimesheetCLI.current_user_id(client)
14
+ harvest_entries = AggregateCLI.fetch_entries(client, from: date, to: date, user_id:).select do |entry|
15
+ AggregateCLI.matches?(entry, project: options[:harvest_project], task: options[:task])
16
+ end
17
+ print_reconciliation(
18
+ output,
19
+ date:,
20
+ local_project: options[:project],
21
+ intervals: local_intervals(log_path, date:, project: options[:project]),
22
+ harvest_entries:
23
+ )
24
+ 0
25
+ rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error, Errno::ENOENT, JSON::ParserError => e
26
+ error.puts "Error: #{e.message}"
27
+ error.puts parser if parser
28
+ 1
29
+ end
30
+
31
+ def self.option_parser(options)
32
+ OptionParser.new do |opts|
33
+ opts.banner = "Usage: harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]"
34
+ opts.on("--project NAME", "Filter local OMP Project Time project name") { |value| options[:project] = value }
35
+ opts.on("--harvest-project NAME", "Filter manual Harvest project name") { |value| options[:harvest_project] = value }
36
+ opts.on("--task NAME", "Filter manual Harvest task name") { |value| options[:task] = value }
37
+ opts.on("-h", "--help", "Show this help") do
38
+ puts opts
39
+ exit 0
40
+ end
41
+ end
42
+ end
43
+
44
+ def self.validate!(dates, options)
45
+ raise Error, "DATE is required" unless dates.length == 1
46
+ %i[project harvest_project].each do |name|
47
+ raise Error, "--#{name.to_s.tr('_', '-')} is required" unless options[name]&.strip&.length&.positive?
48
+ end
49
+ raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
50
+ end
51
+
52
+ def self.default_log_path
53
+ File.join(Dir.home, ".omp", "project-time", "time-log.json")
54
+ end
55
+
56
+ def self.local_intervals(log_path, date:, project:)
57
+ state = JSON.parse(File.read(log_path))
58
+ entries = state.is_a?(Hash) ? state["entries"] : nil
59
+ raise Error, "OMP Project Time log is missing an entries array" unless entries.is_a?(Array)
60
+
61
+ day_start = Time.local(date.year, date.month, date.day).to_f * 1000
62
+ next_date = date + 1
63
+ day_end = Time.local(next_date.year, next_date.month, next_date.day).to_f * 1000
64
+ entries.filter_map do |entry|
65
+ next unless entry.is_a?(Hash) && entry["sourceKind"] == "human_active" && entry["project"] == project
66
+
67
+ start_at = entry["startAtMs"]
68
+ end_at = entry["endAtMs"]
69
+ 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
70
+
71
+ start_at = [start_at, day_start].max
72
+ end_at = [end_at, day_end].min
73
+ [start_at, end_at] if start_at < end_at
74
+ end
75
+ end
76
+
77
+ def self.print_reconciliation(output, date:, local_project:, intervals:, harvest_entries:)
78
+ raw = intervals.sum { |start_at, end_at| end_at - start_at }
79
+ union = merged_duration(intervals)
80
+ harvest = AggregateCLI.total_hours(harvest_entries) * 3_600_000
81
+
82
+ output.puts "#{date.iso8601} reconciliation"
83
+ output.puts "Manual Harvest: #{duration(harvest)} (#{harvest_entries.length} #{AggregateCLI.entry_label(harvest_entries.length)})"
84
+ output.puts "Local OMP Project Time #{local_project}:"
85
+ output.puts " Raw intervals: #{duration(raw)}"
86
+ output.puts " Non-overlapping union: #{duration(union)}"
87
+ output.puts " Concurrent overlap: #{duration(raw - union)}"
88
+ output.puts "Harvest minus local raw: #{signed_duration(harvest - raw)}"
89
+ output.puts "Harvest minus local union: #{signed_duration(harvest - union)}"
90
+ output.puts "Verdict: Manual Harvest is the benchmark. This read-only comparison creates or changes no entries."
91
+ end
92
+
93
+ def self.merged_duration(intervals)
94
+ merged = intervals.sort_by(&:first).each_with_object([]) do |(start_at, end_at), result|
95
+ if result.empty? || start_at > result.last.last
96
+ result << [start_at, end_at]
97
+ else
98
+ result.last[1] = [result.last[1], end_at].max
99
+ end
100
+ end
101
+ merged.sum { |start_at, end_at| end_at - start_at }
102
+ end
103
+
104
+ def self.signed_duration(milliseconds)
105
+ seconds = (milliseconds / 1000.0).round
106
+ return duration(0) if seconds.zero?
107
+
108
+ "#{seconds.negative? ? '-' : '+'}#{duration(seconds.abs * 1000)}"
109
+ end
110
+
111
+ def self.duration(milliseconds)
112
+ seconds = (milliseconds / 1000.0).round
113
+ "%dh %dm %ds" % [seconds / 3600, seconds / 60 % 60, seconds % 60]
114
+ end
115
+ end
116
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HarvestWorklog
4
- VERSION = "0.10.6"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -39,13 +39,15 @@ module HarvestWorklog
39
39
  AggregateCLI.run(command_arguments, output:, error:, client:)
40
40
  when "timesheet"
41
41
  TimesheetCLI.run(command_arguments, output:, error:, client:)
42
+ when "reconcile"
43
+ ReconcileCLI.run(command_arguments, output:, error:, client:)
42
44
  when "mapping-data"
43
45
  MappingDataCLI.run(command_arguments, output:, error:, client:)
44
46
  when "-h", "--help"
45
47
  output.puts usage
46
48
  0
47
49
  else
48
- error.puts "Error: choose time-off, work-entry, aggregate, timesheet, or mapping-data"
50
+ error.puts "Error: choose time-off, work-entry, aggregate, timesheet, reconcile, or mapping-data"
49
51
  error.puts usage
50
52
  1
51
53
  end
@@ -60,6 +62,7 @@ module HarvestWorklog
60
62
  harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
61
63
  harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
62
64
  harvest-worklog timesheet DATE --project NAME [--task NAME]
65
+ harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
63
66
  harvest-worklog mapping-data FROM TO
64
67
 
65
68
  Commands:
@@ -67,6 +70,7 @@ module HarvestWorklog
67
70
  work-entry Create one reviewed ordinary-work entry.
68
71
  aggregate Read time-entry totals without writing Harvest records.
69
72
  timesheet Read a compact daily project timesheet without writing Harvest records.
73
+ reconcile Compare local Project Time with manual Harvest without writing.
70
74
  mapping-data Read assigned Harvest destinations and historical entries without writing.
71
75
  USAGE
72
76
  end
@@ -191,4 +195,5 @@ end
191
195
  require "harvest_worklog/work_entry_cli"
192
196
  require "harvest_worklog/aggregate_cli"
193
197
  require "harvest_worklog/timesheet_cli"
198
+ require "harvest_worklog/reconcile_cli"
194
199
  require "harvest_worklog/mapping_data_cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harvest-worklog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.6
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
@@ -66,6 +66,7 @@ files:
66
66
  - lib/harvest_worklog.rb
67
67
  - lib/harvest_worklog/aggregate_cli.rb
68
68
  - lib/harvest_worklog/mapping_data_cli.rb
69
+ - lib/harvest_worklog/reconcile_cli.rb
69
70
  - lib/harvest_worklog/timesheet_cli.rb
70
71
  - lib/harvest_worklog/version.rb
71
72
  - lib/harvest_worklog/work_entry_cli.rb