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 +4 -4
- data/README.md +9 -7
- data/lib/harvest_worklog/reconcile_cli.rb +116 -0
- data/lib/harvest_worklog/version.rb +1 -1
- data/lib/harvest_worklog.rb +6 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65c8eff780c91f0fc513e021af972f0a2b72a51a0a0b5d80a1854c112816a8e7
|
|
4
|
+
data.tar.gz: 708ec5a2a2b7bda162623c2694eca533431dd7fe0592560bc97300c4614fa59d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
##
|
|
105
|
+
## Daily reconciliation
|
|
105
106
|
|
|
106
|
-
|
|
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
|
|
110
|
-
--project
|
|
110
|
+
harvest-worklog reconcile 2026-07-21 \
|
|
111
|
+
--project wrap \
|
|
112
|
+
--harvest-project WRAP \
|
|
111
113
|
--task Programming
|
|
112
114
|
```
|
|
113
115
|
|
|
114
|
-
|
|
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
|
data/lib/harvest_worklog.rb
CHANGED
|
@@ -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.
|
|
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
|