harvest-worklog 0.13.11 → 0.13.12

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: e5058be1a3d81258a1e210ed3fa1a94f170dcb75ce27aee043c325b03cb321d8
4
- data.tar.gz: 5351dcf95f4377f13abb294a4505f9fedb893f15239086531c25089f3f92e45d
3
+ metadata.gz: 694f2a596182beb6a240d9917c456acdf9e329661383db4e1c5159ea19c58663
4
+ data.tar.gz: bf66d8629771e93f21934cdcbb99e7f7d7cac31ddfccf098da22d38df3fe1997
5
5
  SHA512:
6
- metadata.gz: 1100e335afe91f89640198a70dd564d9394ddd5331280765b88494012801755a10315998b55c2226ebd3c9573ce60e4f2f4acc65ef106d1f686d4027071d7442
7
- data.tar.gz: f63ac0b3b3b8f2ac3ad49a59e3c9c7169bac83ab0281360b665d7e6a4f8e01e6d3b2773d28f07872bf5f4a00746bdd21f10ba49ed4bc028f72538cfbb4a34176
6
+ metadata.gz: 280ca40bcb6493adb43a9992c36b4fb1810443ed1af1a0bdc96560441a5ec8c5966d7b6af3d34bbdfd398fe9add4bbb784057dcf8a7b9eeb1e64fbfd6b2dc9f7
7
+ data.tar.gz: 74ad6fa40e341387c9946f1dbe1fcff057a7075aca57654d4a2ff39a748eb7126375488046e0d52c6388b903bffbf53fb7597ab684641d92fab02c0158b81ae4
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Harvest Worklog
2
2
 
3
- Harvest Worklog is a Ruby CLI and OMP plugin that infers reviewable personal work timesheets from local Project Time evidence while keeping Harvest authoritative. It creates holiday-aware time-off entries and writes only explicitly requested ordinary-work entries.
3
+ Harvest Worklog is a Project Time-driven Ruby CLI and OMP plugin that builds reviewable personal work timesheets from local evidence. Harvest remains a manual destination: the only Harvest operations are explicitly requested time-off and ordinary-work writes.
4
4
 
5
5
  ## Credentials
6
6
 
@@ -24,9 +24,6 @@ harvest-worklog time-off FROM TO --project NAME --task NAME [options]
24
24
  harvest-worklog time-off FROM TO --project-id ID --task-id ID [options]
25
25
  harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
26
26
  harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
27
- harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
28
- harvest-worklog timesheet DATE --project NAME [--task NAME]
29
- harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
30
27
  ```
31
28
 
32
29
  ### Time off
@@ -49,40 +46,29 @@ The `harvest_record_time_off` OMP tool accepts the same name pair or ID pair. It
49
46
 
50
47
  ### Reviewed ordinary work
51
48
 
52
- The `work-entry` command checks for existing or locked entries for its project, task, and date before creating a new duration entry. Use `--dry-run` to complete that preflight without a write. OMP Project Time uses the same path.
49
+ The `work-entry` command checks for existing or locked entries for its project, task, and date before creating a new duration entry. Use `--dry-run` to complete that preflight without a write.
53
50
 
54
- ### Read-only aggregates
51
+ ### Project Time timesheets
55
52
 
56
- The `aggregate` command reads all matching Harvest time-entry pages and prints totals grouped by spent date and project/task. It lists every date in the inclusive range, including weekend or empty dates, and never writes a Harvest record.
53
+ `/harvest-worklog timesheet DATE` reads local `human_active` Project Time evidence and produces one review-only draft for every local project active on that day. It does not call Harvest or require Harvest credentials.
57
54
 
58
- ```bash
59
- harvest-worklog aggregate 2026-07-17 2026-07-19 \
60
- --project WRAP \
61
- --task Programming
62
- ```
63
-
64
- The `harvest_time_aggregates` OMP tool exposes the same optional filters and is approval-gated as a read.
65
-
66
- ### Timesheets
55
+ Use `--project PROJECT` only to restrict the draft to one exact local Project Time project:
67
56
 
68
- The CLI `timesheet` command and `harvest_time_sheet` OMP tool read the authenticated user's compact daily Harvest view. They show task totals, entry durations, and notes already recorded in Harvest.
69
-
70
- ```bash
71
- harvest-worklog timesheet today --project WRAP
57
+ ```text
58
+ /harvest-worklog timesheet yesterday
59
+ /harvest-worklog timesheet yesterday --project wrap
72
60
  ```
73
61
 
74
- The `/harvest-worklog timesheet today --project wrap` slash command reads the requested local OMP Project Time day and renders a deterministic `Inferred work-timesheet draft (review only; nothing written)`. Only `projectTimeMappings` can supply its Harvest project/task destination. Explicit and carried-forward evidence is mapped; unassigned and ambiguous task evidence stays visible but not submittable. Every source entry shows its ID, source kind, repository identity, interval, task-attribution provenance, and any source narrative. The command never calls or changes Harvest.
75
- When a Project Time log has no narrative text, the draft does not turn activity labels into Harvest notes; it asks the user to add factual detail.
76
- `/project-time history` reports all logged dates, so its totals can be larger than the selected day.
62
+ A configured `projectTimeMappings` destination becomes the draft's `Project` and `Task`. Evidence with no mapping, an ambiguous work item, or an unassigned work item remains visible under its local project with `Task: Review destination`; choose the Harvest project and task before submitting. Every source entry includes its ID, source kind, repository identity, interval, task-attribution provenance, and optional narrative. Nothing is submitted automatically.
77
63
 
78
- Type `/harvest-worklog ` in OMP to discover `timesheet`; date aliases appear only after selecting `timesheet `. After `--project`, Tab lists local human-active Project Time project names; a prefix filters them case-insensitively. Completion preserves the case-sensitive name required by the command.
64
+ Type `/harvest-worklog ` in OMP to discover `timesheet`; date aliases appear after selecting `timesheet `. After `--project`, Tab lists local human-active Project Time project names. Completion preserves the exact local project name.
79
65
 
80
66
  ## OMP settings
81
67
 
82
68
  - `defaultHours`: hours per business day when a time-off tool call omits `hours`; defaults to `7`.
83
69
  - `holidayRegions`: comma-separated Holidays regions; defaults to `ca_yt`.
84
70
  - `command`: direct path to the `harvest-worklog` executable.
85
- - `projectTimeMappings`: JSON mapping from OMP Project Time project names to Harvest project/task names.
71
+ - `projectTimeMappings`: optional JSON mapping from OMP Project Time project names to Harvest project/task names. Unmapped evidence remains a review-required local draft.
86
72
  - `projectTimeLogPath`: optional override for `~/.omp/project-time/time-log.json`.
87
73
 
88
74
  ## OMP Project Time integration
@@ -97,29 +83,13 @@ Configure `projectTimeMappings` with the recorded OMP Project Time project name
97
83
 
98
84
  Harvest Worklog reads only the documented `omp-project-time/evidence` format at version `1`. An absent, unsupported, or malformed version produces a no-write diagnostic; run a current Project Time session to persist the v1 ledger format before retrying.
99
85
 
100
- `harvest_preview_project_time_entries` reads only `human_active` sessions from the configured time log, splits them across local dates, combines sources that map to the same Harvest date/project/task, and performs a no-write Harvest preflight. It reports the inferred timesheet entries, source policy, and unmapped sessions; it never records Project Time-derived work in Harvest.
101
-
102
- `harvest_preview_project_time_drafts` produces one deterministic, copyable Date/Project/Task/Duration block per mapped local day and Harvest destination from `human_active` evidence. It preserves source entry IDs, intervals, source kind, repository identity, narratives, work items, and task-attribution provenance. Explicit and carried-forward evidence can use the existing project-to-Harvest mapping; unassigned and ambiguous evidence stays visible but not submittable. Generated narratives are review evidence, never task identity or a factual Harvest note. The tool reads the local log only; it never calls or mutates Harvest.
86
+ `harvest_preview_project_time_drafts` produces deterministic, copyable Date/Project/Task/Duration blocks from all local `human_active` evidence in an inclusive date range. Mapped evidence uses its configured Harvest destination; unmapped, ambiguous, and unassigned evidence remains an explicit local-project draft. It preserves source entry IDs, intervals, source kind, repository identity, narratives, work items, and task-attribution provenance. Generated narratives are review evidence, never task identity or a factual Harvest note. The tool reads the local log only; it never calls or mutates Harvest.
103
87
 
104
88
  ### Activity transforms
105
89
 
106
- `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, source records, candidate mapped entries, and any unmapped or excluded rows; it never writes Harvest.
107
-
108
-
109
- ## Daily reconciliation
110
-
111
- `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.
112
-
113
- ```bash
114
- harvest-worklog reconcile 2026-07-21 \
115
- --project wrap \
116
- --harvest-project WRAP \
117
- --task Programming
118
- ```
90
+ `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 create configured Harvest destinations or review-required local destinations for every included group. The output reports groups, draft entries, and excluded rows; it never writes Harvest.
119
91
 
120
- Empty local or Harvest data is reported as zero, so this command is suitable for daily capture checks while manual tracking remains authoritative. It never decides or applies a Harvest delta.
121
92
 
122
- Manual Harvest entries remain separate from inferred Project Time durations. Review the timesheet and reconciliation output, then choose the actual Harvest entry yourself; do not sum local evidence and manually recorded Harvest time as if they were independent entries.
123
93
 
124
94
  ## Migration from harvest-time-off
125
95
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HarvestWorklog
4
- VERSION = "0.13.11"
4
+ VERSION = "0.13.12"
5
5
  end
@@ -35,19 +35,11 @@ module HarvestWorklog
35
35
  TimeOffCLI.run(command_arguments, output:, error:, client:)
36
36
  when "work-entry"
37
37
  WorkEntryCLI.run(command_arguments, output:, error:, client:)
38
- when "aggregate"
39
- AggregateCLI.run(command_arguments, output:, error:, client:)
40
- when "timesheet"
41
- TimesheetCLI.run(command_arguments, output:, error:, client:)
42
- when "reconcile"
43
- ReconcileCLI.run(command_arguments, output:, error:, client:)
44
- when "mapping-data"
45
- MappingDataCLI.run(command_arguments, output:, error:, client:)
46
38
  when "-h", "--help"
47
39
  output.puts usage
48
40
  0
49
41
  else
50
- error.puts "Error: choose time-off, work-entry, aggregate, timesheet, reconcile, or mapping-data"
42
+ error.puts "Error: choose time-off or work-entry"
51
43
  error.puts usage
52
44
  1
53
45
  end
@@ -60,18 +52,10 @@ module HarvestWorklog
60
52
  harvest-worklog time-off FROM TO --project-id ID --task-id ID [options]
61
53
  harvest-worklog work-entry DATE --project NAME --task NAME --hours HOURS --notes NOTES [options]
62
54
  harvest-worklog work-entry DATE --project-id ID --task-id ID --hours HOURS --notes NOTES [options]
63
- harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]
64
- harvest-worklog timesheet DATE --project NAME [--task NAME]
65
- harvest-worklog reconcile DATE --project PROJECT --harvest-project NAME [--task NAME]
66
- harvest-worklog mapping-data FROM TO
67
55
 
68
56
  Commands:
69
57
  time-off Create one entry per local business day in a date range.
70
58
  work-entry Create one reviewed ordinary-work entry.
71
- aggregate Read time-entry totals without writing Harvest records.
72
- timesheet Read a compact daily project timesheet without writing Harvest records.
73
- reconcile Compare local Project Time with manual Harvest without writing.
74
- mapping-data Read assigned Harvest destinations and historical entries without writing.
75
59
  USAGE
76
60
  end
77
61
  end
@@ -193,7 +177,3 @@ module HarvestWorklog
193
177
  end
194
178
 
195
179
  require "harvest_worklog/work_entry_cli"
196
- require "harvest_worklog/aggregate_cli"
197
- require "harvest_worklog/timesheet_cli"
198
- require "harvest_worklog/reconcile_cli"
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.13.11
4
+ version: 0.13.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
@@ -66,10 +66,6 @@ files:
66
66
  - bin/verify-plugin-release
67
67
  - harvest-worklog.rb
68
68
  - lib/harvest_worklog.rb
69
- - lib/harvest_worklog/aggregate_cli.rb
70
- - lib/harvest_worklog/mapping_data_cli.rb
71
- - lib/harvest_worklog/reconcile_cli.rb
72
- - lib/harvest_worklog/timesheet_cli.rb
73
69
  - lib/harvest_worklog/version.rb
74
70
  - lib/harvest_worklog/work_entry_cli.rb
75
71
  homepage: https://github.com/klondikemarlen/harvest-worklog
@@ -95,5 +91,5 @@ requirements: []
95
91
  rubygems_version: 3.5.22
96
92
  signing_key:
97
93
  specification_version: 4
98
- summary: Harvest work-log CLI and OMP integration.
94
+ summary: Project Time work-timesheet drafts with manual Harvest writes.
99
95
  test_files: []
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HarvestWorklog
4
- class AggregateCLI
5
- PER_PAGE = 100
6
-
7
- def self.run(arguments, output: $stdout, error: $stderr, client: nil)
8
- options = {}
9
- parser = option_parser(options)
10
- dates = parser.parse(arguments)
11
- validate!(dates, options)
12
-
13
- from = Date.iso8601(dates[0])
14
- to = Date.iso8601(dates[1])
15
- raise Error, "end date must not be before start date" if to < from
16
-
17
- client ||= Marlens::HarvestApiV2::Client.from_environment
18
- entries = fetch_entries(client, from:, to:).select { |entry| matches?(entry, options) }
19
- print_summary(output, entries, from:, to:)
20
- 0
21
- rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
22
- error.puts "Error: #{e.message}"
23
- error.puts parser if parser
24
- 1
25
- end
26
-
27
- def self.option_parser(options)
28
- OptionParser.new do |opts|
29
- opts.banner = "Usage: harvest-worklog aggregate FROM TO [--project NAME] [--task NAME]"
30
- opts.on("--project NAME", "Filter by Harvest project name") { |value| options[:project] = value }
31
- opts.on("--task NAME", "Filter by Harvest task name") { |value| options[:task] = value }
32
- opts.on("-h", "--help", "Show this help") do
33
- puts opts
34
- exit 0
35
- end
36
- end
37
- end
38
-
39
- def self.validate!(dates, options = {})
40
- raise Error, "FROM and TO are required" unless dates.length == 2
41
- raise Error, "--project must not be blank" if options[:project]&.strip&.empty?
42
- raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
43
- end
44
-
45
- def self.fetch_entries(client, from:, to:, user_id: nil)
46
- entries = []
47
- page = 1
48
- loop do
49
- params = { from: from.iso8601, to: to.iso8601, page:, per_page: PER_PAGE }
50
- params[:user_id] = user_id if user_id
51
- response = client.request(
52
- :get,
53
- "/v2/time_entries",
54
- params:
55
- )
56
- entries.concat(response.fetch("time_entries"))
57
- page = response["next_page"]
58
- break if page.nil?
59
- end
60
- entries
61
- end
62
-
63
- def self.matches?(entry, options)
64
- name_matches?(entry.dig("project", "name"), options[:project]) &&
65
- name_matches?(entry.dig("task", "name"), options[:task])
66
- end
67
-
68
- def self.name_matches?(actual, expected)
69
- expected.nil? || actual&.casecmp?(expected)
70
- end
71
-
72
- def self.print_summary(output, entries, from:, to:)
73
- output.puts "#{entries.length} #{entry_label(entries.length)}, #{HarvestWorklog.display_hours(total_hours(entries))}h from #{from.iso8601} through #{to.iso8601}"
74
- output.puts "By date:"
75
- (from..to).each do |date|
76
- date_entries = entries.select { |entry| entry.fetch("spent_date") == date.iso8601 }
77
- output.puts " #{date.iso8601}: #{date_entries.length} #{entry_label(date_entries.length)}, #{HarvestWorklog.display_hours(total_hours(date_entries))}h"
78
- end
79
- output.puts "By project/task:"
80
-
81
- groups = entries.group_by { |entry| [entry.dig("project", "name"), entry.dig("task", "name")] }
82
- if groups.empty?
83
- output.puts " none"
84
- return
85
- end
86
-
87
- groups.keys.sort_by { |project, task| [project.downcase, task.downcase] }.each do |project, task|
88
- group = groups.fetch([project, task])
89
- output.puts " #{project} / #{task}: #{group.length} #{entry_label(group.length)}, #{HarvestWorklog.display_hours(total_hours(group))}h"
90
- end
91
- end
92
-
93
- def self.total_hours(entries)
94
- entries.sum { |entry| Float(entry.fetch("hours")) }
95
- end
96
-
97
- def self.entry_label(count)
98
- count == 1 ? "entry" : "entries"
99
- end
100
- end
101
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module HarvestWorklog
6
- class MappingDataCLI
7
- def self.run(arguments, output: $stdout, error: $stderr, client: nil)
8
- from, to = arguments
9
- raise Error, "FROM and TO are required" unless arguments.length == 2
10
-
11
- from = Date.iso8601(from)
12
- to = Date.iso8601(to)
13
- raise Error, "end date must not be before start date" if to < from
14
-
15
- client ||= Marlens::HarvestApiV2::Client.from_environment
16
- assignments = client.active_personal_task_assignments.filter_map do |assignment|
17
- project = assignment["project"]
18
- task = assignment["task"]
19
- next unless project&.fetch("id", nil) && project&.fetch("name", nil) && task&.fetch("id", nil) && task&.fetch("name", nil)
20
-
21
- { "project" => { "id" => project.fetch("id"), "name" => project.fetch("name") }, "task" => { "id" => task.fetch("id"), "name" => task.fetch("name") } }
22
- end
23
- entries = AggregateCLI.fetch_entries(client, from:, to:).filter_map do |entry|
24
- project = entry["project"]
25
- task = entry["task"]
26
- next unless project&.fetch("id", nil) && project&.fetch("name", nil) && task&.fetch("id", nil) && task&.fetch("name", nil) && Float(entry.fetch("hours"))
27
-
28
- { "project" => { "id" => project.fetch("id"), "name" => project.fetch("name") }, "task" => { "id" => task.fetch("id"), "name" => task.fetch("name") }, "hours" => entry.fetch("hours") }
29
- end
30
- output.puts JSON.generate(assignments:, entries:)
31
- 0
32
- rescue Error, Marlens::HarvestApiV2::Error, Date::Error => e
33
- error.puts "Error: #{e.message}"
34
- 1
35
- end
36
- end
37
- end
@@ -1,116 +0,0 @@
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,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HarvestWorklog
4
- class TimesheetCLI
5
- def self.run(arguments, output: $stdout, error: $stderr, client: nil, today: Date.today)
6
- options = {}
7
- parser = option_parser(options)
8
- dates = parser.parse(arguments)
9
- validate!(dates, options)
10
-
11
- spent_date = resolve_date(dates.first, today:)
12
- client ||= Marlens::HarvestApiV2::Client.from_environment
13
- user_id = current_user_id(client)
14
- entries = AggregateCLI.fetch_entries(client, from: spent_date, to: spent_date, user_id:).select { |entry| AggregateCLI.matches?(entry, options) }
15
- print_timesheet(output, entries, spent_date:, requested_project: options[:project])
16
- 0
17
- rescue Error, Marlens::HarvestApiV2::Error, OptionParser::ParseError, Date::Error => e
18
- error.puts "Error: #{e.message}"
19
- error.puts parser if parser
20
- 1
21
- end
22
-
23
- def self.option_parser(options)
24
- OptionParser.new do |opts|
25
- opts.banner = "Usage: harvest-worklog timesheet DATE --project NAME [--task NAME]"
26
- opts.on("--project NAME", "Filter by Harvest project name") { |value| options[:project] = value }
27
- opts.on("--task NAME", "Filter by Harvest task name") { |value| options[:task] = value }
28
- opts.on("-h", "--help", "Show this help") do
29
- puts opts
30
- exit 0
31
- end
32
- end
33
- end
34
-
35
- def self.resolve_date(value, today: Date.today)
36
- case value.downcase
37
- when "today" then today
38
- when "yesterday" then today - 1
39
- else Date.iso8601(value)
40
- end
41
- end
42
-
43
- def self.current_user_id(client)
44
- client.request(:get, "/v2/users/me", params: {}).fetch("id")
45
- end
46
-
47
- def self.validate!(dates, options)
48
- raise Error, "DATE is required" unless dates.length == 1
49
- raise Error, "--project is required" unless options[:project] && !options[:project].strip.empty?
50
- raise Error, "--task must not be blank" if options[:task]&.strip&.empty?
51
- end
52
-
53
- def self.print_timesheet(output, entries, spent_date:, requested_project:)
54
- project = entries.first&.dig("project", "name") || requested_project
55
- output.puts "#{project} · #{spent_date.strftime("%a, %b %-d")} · #{HarvestWorklog.display_hours(AggregateCLI.total_hours(entries))}h"
56
- if entries.empty?
57
- output.puts
58
- output.puts "No time entries."
59
- return
60
- end
61
-
62
- entries.group_by { |entry| entry.dig("task", "name") }.sort_by { |task, _| task.downcase }.each do |task, task_entries|
63
- output.puts
64
- output.puts "#{task} · #{HarvestWorklog.display_hours(AggregateCLI.total_hours(task_entries))}h"
65
- if task_entries.one?
66
- print_notes(output, task_entries.first["notes"].to_s, indent: 2)
67
- else
68
- task_entries.each do |entry|
69
- output.puts " #{HarvestWorklog.display_hours(Float(entry.fetch("hours")))}h"
70
- print_notes(output, entry["notes"].to_s, indent: 4)
71
- end
72
- end
73
- end
74
- end
75
-
76
- def self.print_notes(output, notes, indent:)
77
- lines = notes.lines(chomp: true).reject(&:empty?)
78
- lines = ["(no notes)"] if lines.empty?
79
- lines.each { |line| output.puts "#{" " * indent}#{line}" }
80
- end
81
- end
82
- end