qtimetrap 0.1.1

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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +25 -0
  3. data/README.md +92 -0
  4. data/Rakefile +105 -0
  5. data/app/assets/icons/qtimetrap-icon-128.png +0 -0
  6. data/app/assets/icons/qtimetrap-icon-256.png +0 -0
  7. data/app/assets/icons/qtimetrap-icon-512.png +0 -0
  8. data/app/assets/icons/qtimetrap-icon.svg +123 -0
  9. data/app/components/entries/branch_hierarchy_helpers.rb +41 -0
  10. data/app/components/entries/leaf_archive_helpers.rb +23 -0
  11. data/app/components/entries/leaf_note_helpers.rb +118 -0
  12. data/app/components/entries/leaf_task_helpers.rb +81 -0
  13. data/app/components/entries/leaf_time_helpers.rb +105 -0
  14. data/app/components/entries/list_component.rb +118 -0
  15. data/app/components/entries/list_host_helpers.rb +27 -0
  16. data/app/components/entries/list_state_helpers.rb +59 -0
  17. data/app/components/entries/node_presentation_helpers.rb +29 -0
  18. data/app/components/entries/render_helpers.rb +25 -0
  19. data/app/components/entries/tree_helpers.rb +133 -0
  20. data/app/components/entries/tree_toolbar_helpers.rb +98 -0
  21. data/app/components/project_sidebar/archive_toggle_helpers.rb +22 -0
  22. data/app/components/project_sidebar/component.rb +125 -0
  23. data/app/components/project_sidebar/logo_helpers.rb +53 -0
  24. data/app/components/project_sidebar/project_button_helpers.rb +72 -0
  25. data/app/components/project_sidebar/project_selection_helpers.rb +94 -0
  26. data/app/components/project_sidebar/task_helpers.rb +94 -0
  27. data/app/components/project_sidebar/task_selection_helpers.rb +72 -0
  28. data/app/components/qt_ui_helpers.rb +27 -0
  29. data/app/components/tracker_controls/component.rb +80 -0
  30. data/app/components/tracker_controls/layout_builder.rb +100 -0
  31. data/app/components/tracker_controls/layout_helpers.rb +88 -0
  32. data/app/models/null_settings_store.rb +12 -0
  33. data/app/models/time_entry.rb +59 -0
  34. data/app/services/archived_entries_store.rb +71 -0
  35. data/app/services/formatters.rb +29 -0
  36. data/app/services/settings_store.rb +83 -0
  37. data/app/services/timetrap_gateway.rb +89 -0
  38. data/app/services/timetrap_gateway_logger.rb +54 -0
  39. data/app/services/timetrap_gateway_query_helpers.rb +102 -0
  40. data/app/services/timetrap_gateway_start_helpers.rb +28 -0
  41. data/app/services/timetrap_gateway_update_note_helpers.rb +48 -0
  42. data/app/services/timetrap_gateway_update_task_helpers.rb +47 -0
  43. data/app/services/timetrap_gateway_update_time_helpers.rb +50 -0
  44. data/app/styles/theme.rb +61 -0
  45. data/app/styles/themes/dark/application.qss +8 -0
  46. data/app/styles/themes/dark/entries_list.qss +235 -0
  47. data/app/styles/themes/dark/project_sidebar.qss +99 -0
  48. data/app/styles/themes/dark/snippets/app_background.qss +2 -0
  49. data/app/styles/themes/dark/snippets/button_ghost.qss +5 -0
  50. data/app/styles/themes/dark/snippets/button_start.qss +5 -0
  51. data/app/styles/themes/dark/snippets/button_stop.qss +5 -0
  52. data/app/styles/themes/dark/snippets/entries_host.qss +2 -0
  53. data/app/styles/themes/dark/snippets/entries_scroll.qss +16 -0
  54. data/app/styles/themes/dark/snippets/entry_row_day.qss +6 -0
  55. data/app/styles/themes/dark/snippets/entry_row_detail.qss +5 -0
  56. data/app/styles/themes/dark/snippets/entry_row_project.qss +6 -0
  57. data/app/styles/themes/dark/snippets/project_button.qss +5 -0
  58. data/app/styles/themes/dark/snippets/project_button_active.qss +5 -0
  59. data/app/styles/themes/dark/snippets/project_sidebar_heading.qss +2 -0
  60. data/app/styles/themes/dark/snippets/project_sidebar_logo.qss +3 -0
  61. data/app/styles/themes/dark/snippets/project_sidebar_panel.qss +2 -0
  62. data/app/styles/themes/dark/snippets/tracker_clock.qss +2 -0
  63. data/app/styles/themes/dark/snippets/tracker_input.qss +5 -0
  64. data/app/styles/themes/dark/snippets/tracker_project_label.qss +5 -0
  65. data/app/styles/themes/dark/snippets/tracker_row.qss +3 -0
  66. data/app/styles/themes/dark/snippets/tracker_summary.qss +3 -0
  67. data/app/styles/themes/dark/snippets/tracker_timer.qss +3 -0
  68. data/app/styles/themes/dark/snippets/tracker_title.qss +3 -0
  69. data/app/styles/themes/dark/snippets/tracker_topbar.qss +2 -0
  70. data/app/styles/themes/dark/tracker_controls.qss +84 -0
  71. data/app/styles/themes/light/application.qss +8 -0
  72. data/app/styles/themes/light/entries_list.qss +235 -0
  73. data/app/styles/themes/light/project_sidebar.qss +99 -0
  74. data/app/styles/themes/light/snippets/app_background.qss +2 -0
  75. data/app/styles/themes/light/snippets/button_ghost.qss +5 -0
  76. data/app/styles/themes/light/snippets/button_start.qss +5 -0
  77. data/app/styles/themes/light/snippets/button_stop.qss +5 -0
  78. data/app/styles/themes/light/snippets/entries_host.qss +2 -0
  79. data/app/styles/themes/light/snippets/entries_scroll.qss +16 -0
  80. data/app/styles/themes/light/snippets/entry_row_day.qss +6 -0
  81. data/app/styles/themes/light/snippets/entry_row_detail.qss +5 -0
  82. data/app/styles/themes/light/snippets/entry_row_project.qss +6 -0
  83. data/app/styles/themes/light/snippets/project_button.qss +5 -0
  84. data/app/styles/themes/light/snippets/project_button_active.qss +5 -0
  85. data/app/styles/themes/light/snippets/project_sidebar_heading.qss +2 -0
  86. data/app/styles/themes/light/snippets/project_sidebar_logo.qss +3 -0
  87. data/app/styles/themes/light/snippets/project_sidebar_panel.qss +2 -0
  88. data/app/styles/themes/light/snippets/tracker_clock.qss +2 -0
  89. data/app/styles/themes/light/snippets/tracker_input.qss +5 -0
  90. data/app/styles/themes/light/snippets/tracker_project_label.qss +5 -0
  91. data/app/styles/themes/light/snippets/tracker_row.qss +3 -0
  92. data/app/styles/themes/light/snippets/tracker_summary.qss +3 -0
  93. data/app/styles/themes/light/snippets/tracker_timer.qss +3 -0
  94. data/app/styles/themes/light/snippets/tracker_title.qss +3 -0
  95. data/app/styles/themes/light/snippets/tracker_topbar.qss +2 -0
  96. data/app/styles/themes/light/tracker_controls.qss +84 -0
  97. data/app/view_models/entry_nodes_builder.rb +109 -0
  98. data/app/view_models/main_view_model.rb +135 -0
  99. data/app/view_models/main_view_model_archive_mode_helpers.rb +102 -0
  100. data/app/view_models/main_view_model_entry_note_helpers.rb +30 -0
  101. data/app/view_models/main_view_model_entry_task_helpers.rb +39 -0
  102. data/app/view_models/main_view_model_entry_time_helpers.rb +45 -0
  103. data/app/view_models/main_view_model_sheet_helpers.rb +74 -0
  104. data/app/view_models/main_view_model_task_filter_helpers.rb +58 -0
  105. data/app/view_models/main_view_model_time_range_filter_helpers.rb +23 -0
  106. data/app/views/main_window.rb +126 -0
  107. data/app/views/main_window_layout_builder.rb +114 -0
  108. data/app/views/main_window_runtime.rb +123 -0
  109. data/app/views/main_window_runtime_archive_helpers.rb +26 -0
  110. data/app/views/main_window_runtime_entry_task_helpers.rb +21 -0
  111. data/app/views/main_window_runtime_entry_time_helpers.rb +17 -0
  112. data/app/views/main_window_runtime_key_helpers.rb +55 -0
  113. data/app/views/main_window_runtime_render_helpers.rb +50 -0
  114. data/app/views/main_window_splitter_toggle_bootstrap_helpers.rb +34 -0
  115. data/app/views/main_window_splitter_toggle_helpers.rb +103 -0
  116. data/app/views/main_window_splitter_toggle_hover_helpers.rb +71 -0
  117. data/app/views/main_window_splitter_toggle_layout_helpers.rb +24 -0
  118. data/app/views/main_window_ui_helpers.rb +45 -0
  119. data/app/views/window_icon_loader.rb +37 -0
  120. data/bin/qtimetrap +7 -0
  121. data/config/application.rb +87 -0
  122. data/config/environments/development.rb +5 -0
  123. data/config/environments/production.rb +5 -0
  124. data/config/initializers/theme.rb +5 -0
  125. data/config/initializers/timetrap.rb +5 -0
  126. data/lib/qtimetrap/configuration.rb +15 -0
  127. data/lib/qtimetrap/container.rb +57 -0
  128. data/lib/qtimetrap/version.rb +5 -0
  129. data/lib/qtimetrap.rb +25 -0
  130. metadata +207 -0
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module TrackerControls
5
+ # Builds tracker controls widget tree and wires all button callbacks.
6
+ class LayoutBuilder
7
+ include QtUiHelpers
8
+ include LayoutHelpers
9
+
10
+ def initialize(parent:, callbacks:)
11
+ @parent = parent
12
+ @callbacks = callbacks
13
+ end
14
+
15
+ def build
16
+ @widget = QWidget.new(parent)
17
+ root = build_root_layout
18
+ root.add_widget(build_topbar)
19
+ start_button, stop_button = build_tracker_row(root)
20
+ refresh_button = build_actions_row(root)
21
+ connect_actions(start_button, stop_button, refresh_button)
22
+ ui_payload
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :parent, :widget, :callbacks
28
+
29
+ def build_topbar
30
+ topbar = QWidget.new(widget)
31
+ topbar.set_object_name('topbar')
32
+ layout = QHBoxLayout.new(topbar)
33
+ configure_topbar_layout(layout, topbar)
34
+ topbar
35
+ end
36
+
37
+ def build_tracker_row(root_layout)
38
+ row = QWidget.new(widget)
39
+ row.set_object_name('tracker_row')
40
+ layout = QHBoxLayout.new(row)
41
+ layout.set_contents_margins(14, 12, 14, 12)
42
+ layout.set_spacing(8)
43
+ start_button, stop_button = add_tracker_row_widgets(layout, row)
44
+ root_layout.add_widget(row)
45
+ [start_button, stop_button]
46
+ end
47
+
48
+ def build_actions_row(root_layout)
49
+ row = QWidget.new(widget)
50
+ layout = QHBoxLayout.new(row)
51
+ layout.set_contents_margins(0, 0, 0, 0)
52
+ layout.set_spacing(8)
53
+ refresh_button = add_actions_row_widgets(layout, row)
54
+ root_layout.add_widget(row)
55
+ refresh_button
56
+ end
57
+
58
+ def build_task_input(parent_widget)
59
+ QLineEdit.new(parent_widget).tap do |input|
60
+ input.set_object_name('task_input')
61
+ input.set_placeholder_text('What are you working on?')
62
+ input.set_focus_policy(Qt::ClickFocus)
63
+ input.text = ''
64
+ end
65
+ end
66
+
67
+ def build_project_input(parent_widget)
68
+ QLineEdit.new(parent_widget).tap do |input|
69
+ input.set_object_name('project_input')
70
+ input.set_placeholder_text('your project')
71
+ input.set_focus_policy(Qt::ClickFocus)
72
+ input.text = ''
73
+ input.set_fixed_width(190)
74
+ end
75
+ end
76
+
77
+ def connect_actions(start_button, stop_button, refresh_button)
78
+ connect_start_stop(start_button, stop_button)
79
+ connect_theme_refresh(refresh_button)
80
+ connect_project_input
81
+ end
82
+
83
+ def connect_start_stop(start_button, stop_button)
84
+ start_button.connect('clicked') do |_|
85
+ callbacks.fetch(:on_start).call(@task_input.text.to_s, @project_input.text.to_s)
86
+ end
87
+ stop_button.connect('clicked') { |_| callbacks.fetch(:on_stop).call }
88
+ end
89
+
90
+ def connect_theme_refresh(refresh_button)
91
+ @theme_button.connect('clicked') { |_| callbacks.fetch(:on_switch_theme).call }
92
+ refresh_button.connect('clicked') { |_| callbacks.fetch(:on_refresh).call }
93
+ end
94
+
95
+ def connect_project_input
96
+ @project_input.connect('textChanged(QString)') { |text| callbacks.fetch(:on_project_change).call(text.to_s) }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module TrackerControls
5
+ # Extracted helper methods for tracker controls layout assembly.
6
+ module LayoutHelpers
7
+ TITLE_SLOGANS = [
8
+ 'Track time, ship value',
9
+ 'Small steps, big output',
10
+ 'Focus. Build. Finish.',
11
+ 'Consistency beats intensity',
12
+ "Today's minutes, tomorrow's results",
13
+ 'Make progress visible',
14
+ 'Do the next right task'
15
+ ].freeze
16
+
17
+ private
18
+
19
+ def build_root_layout
20
+ QVBoxLayout.new(widget).tap do |layout|
21
+ layout.set_contents_margins(0, 8, 0, 0)
22
+ layout.set_spacing(14)
23
+ end
24
+ end
25
+
26
+ def configure_topbar_layout(layout, topbar)
27
+ layout.set_contents_margins(16, 8, 16, 8)
28
+ layout.set_spacing(8)
29
+ layout.add_widget(build_label(topbar, 'title_label', random_title_slogan))
30
+ layout.add_stretch(1)
31
+ @clock_label = build_label(topbar, 'clock_label', nil, width: 220)
32
+ @clock_label.set_alignment(Qt::AlignCenter)
33
+ layout.add_widget(@clock_label)
34
+ end
35
+
36
+ def add_tracker_row_widgets(layout, row)
37
+ @task_input = build_task_input(row)
38
+ @project_input = build_project_input(row)
39
+ @timer_label = build_label(row, 'timer_label', '00:00:00', width: 120)
40
+ @timer_label.set_alignment(Qt::AlignCenter)
41
+ @start_button = build_button(row, 'start_button', 'START', 64, 48)
42
+ @stop_button = build_button(row, 'stop_button', 'STOP', 64, 48)
43
+ @stop_button.hide
44
+ [@task_input, @project_input, @timer_label, @start_button, @stop_button].each { |item| layout.add_widget(item) }
45
+ [@start_button, @stop_button]
46
+ end
47
+
48
+ def add_actions_row_widgets(layout, row)
49
+ @summary_label = build_label(row, 'summary_label', nil, height: 42)
50
+ @summary_label.set_alignment(Qt::AlignCenter)
51
+ @theme_button = build_button(row, 'theme_button', 'THEME', 112, 34)
52
+ refresh_button = build_button(row, 'refresh_button', 'REFRESH', 110, 34)
53
+ layout.add_widget(@summary_label)
54
+ layout.add_stretch(1)
55
+ layout.add_widget(@theme_button)
56
+ layout.add_widget(refresh_button)
57
+ refresh_button
58
+ end
59
+
60
+ def ui_payload
61
+ ui_core_payload.merge(ui_controls_payload)
62
+ end
63
+
64
+ def ui_core_payload
65
+ {
66
+ widget: @widget,
67
+ task_input: @task_input,
68
+ clock_label: @clock_label,
69
+ timer_label: @timer_label
70
+ }
71
+ end
72
+
73
+ def ui_controls_payload
74
+ {
75
+ summary_label: @summary_label,
76
+ project_input: @project_input,
77
+ theme_button: @theme_button,
78
+ start_button: @start_button,
79
+ stop_button: @stop_button
80
+ }
81
+ end
82
+
83
+ def random_title_slogan
84
+ TITLE_SLOGANS.sample
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Models
5
+ # No-op settings persistence implementation used as a safe fallback.
6
+ class NullSettingsStore
7
+ def write_theme_name(_theme_name); end
8
+ def read_window_geometry; end
9
+ def write_window_geometry(left:, top:, width:, height:); end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module QTimetrap
6
+ module Models
7
+ # Immutable time tracking entry model used across view and services.
8
+ class TimeEntry
9
+ attr_reader :id, :note, :sheet, :start_time, :end_time
10
+
11
+ def initialize(id:, note:, sheet:, start_time:, end_time:)
12
+ @id = id
13
+ @note = note.to_s
14
+ @sheet = sheet.to_s
15
+ @start_time = start_time
16
+ @end_time = end_time
17
+ end
18
+
19
+ def running?
20
+ end_time.nil?
21
+ end
22
+
23
+ def duration_seconds(now: Time.now)
24
+ return 0 unless start_time
25
+
26
+ finish = end_time || now
27
+ [finish.to_i - start_time.to_i, 0].max
28
+ end
29
+
30
+ def project
31
+ split_sheet.first
32
+ end
33
+
34
+ def task
35
+ split_sheet.last
36
+ end
37
+
38
+ def day
39
+ (start_time || Time.now).to_date
40
+ end
41
+
42
+ private
43
+
44
+ def split_sheet
45
+ raw = sheet.strip
46
+ return ['(default)', '(default task)'] if raw.empty?
47
+
48
+ parts = raw.split('|', 2)
49
+ return [raw, '(default task)'] unless parts.size == 2
50
+
51
+ project = parts.first.strip
52
+ task = parts.last.strip
53
+ project = '(default)' if project.empty?
54
+ task = '(default task)' if task.empty?
55
+ [project, task]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+
6
+ module QTimetrap
7
+ module Services
8
+ # Persists archived Timetrap entry ids in a local app-owned store.
9
+ class ArchivedEntriesStore
10
+ def initialize(path: default_path)
11
+ @path = path
12
+ end
13
+
14
+ def archived_ids
15
+ read_ids
16
+ end
17
+
18
+ def archived?(entry_id)
19
+ read_ids.include?(Integer(entry_id))
20
+ rescue ArgumentError, TypeError
21
+ false
22
+ end
23
+
24
+ def archive(entry_id)
25
+ id = Integer(entry_id)
26
+ ids = read_ids
27
+ return if ids.include?(id)
28
+
29
+ ids << id
30
+ write_ids(ids)
31
+ rescue ArgumentError, TypeError
32
+ nil
33
+ end
34
+
35
+ def unarchive(entry_id)
36
+ id = Integer(entry_id)
37
+ ids = read_ids
38
+ return unless ids.delete(id)
39
+
40
+ write_ids(ids)
41
+ rescue ArgumentError, TypeError
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :path
48
+
49
+ def read_ids
50
+ return [] unless File.exist?(path)
51
+
52
+ payload = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
53
+ return [] unless payload.is_a?(Hash)
54
+
55
+ Array(payload['archived_entry_ids']).filter_map { |value| Integer(value) }.uniq.sort
56
+ rescue Psych::SyntaxError, ArgumentError, TypeError
57
+ []
58
+ end
59
+
60
+ def write_ids(ids)
61
+ FileUtils.mkdir_p(File.dirname(path))
62
+ File.write(path, { 'archived_entry_ids' => ids.uniq.sort }.to_yaml)
63
+ end
64
+
65
+ def default_path
66
+ data_home = ENV.fetch('XDG_DATA_HOME', File.join(Dir.home, '.local', 'share'))
67
+ File.join(data_home, 'qtimetrap', 'archived_entries.yml')
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Services
5
+ # Stateless value formatters used by view models and UI rendering.
6
+ module Formatters
7
+ module_function
8
+
9
+ def seconds_to_hms(seconds)
10
+ seconds = [seconds.to_i, 0].max
11
+ hours = seconds / 3600
12
+ minutes = (seconds % 3600) / 60
13
+ secs = seconds % 60
14
+ format('%<h>02d:%<m>02d:%<s>02d', h: hours, m: minutes, s: secs)
15
+ end
16
+
17
+ def time_range(entry)
18
+ start_label, finish_label = time_bounds(entry)
19
+ "#{start_label} - #{finish_label}"
20
+ end
21
+
22
+ def time_bounds(entry)
23
+ start_label = entry.start_time ? entry.start_time.strftime('%H:%M') : '--:--'
24
+ finish_label = entry.end_time ? entry.end_time.strftime('%H:%M') : 'running'
25
+ [start_label, finish_label]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+
6
+ module QTimetrap
7
+ module Services
8
+ # Persists lightweight UI settings in a YAML file under user config.
9
+ class SettingsStore
10
+ def initialize(path: default_path)
11
+ @path = path
12
+ end
13
+
14
+ def read_theme_name
15
+ data['theme']
16
+ end
17
+
18
+ def write_theme_name(theme_name)
19
+ value = theme_name.to_s.strip
20
+ return if value.empty?
21
+
22
+ payload = data.merge('theme' => value)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ File.write(path, payload.to_yaml)
25
+ end
26
+
27
+ def read_window_geometry
28
+ value = data['window']
29
+ return nil unless value.is_a?(Hash)
30
+
31
+ geometry = stringify_keys(value)
32
+ parse_window_geometry(geometry)
33
+ end
34
+
35
+ def write_window_geometry(left:, top:, width:, height:)
36
+ payload = data.merge(
37
+ 'window' => {
38
+ 'left' => Integer(left),
39
+ 'top' => Integer(top),
40
+ 'width' => Integer(width),
41
+ 'height' => Integer(height)
42
+ }
43
+ )
44
+ FileUtils.mkdir_p(File.dirname(path))
45
+ File.write(path, payload.to_yaml)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :path
51
+
52
+ def data
53
+ return {} unless File.exist?(path)
54
+
55
+ loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
56
+ loaded.is_a?(Hash) ? stringify_keys(loaded) : {}
57
+ rescue Psych::SyntaxError
58
+ {}
59
+ end
60
+
61
+ def stringify_keys(hash)
62
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
63
+ end
64
+
65
+ def parse_window_geometry(geometry)
66
+ left = Integer(geometry['left'] || geometry['x'])
67
+ top = Integer(geometry['top'] || geometry['y'])
68
+ width = Integer(geometry['width'])
69
+ height = Integer(geometry['height'])
70
+ return nil unless width.positive? && height.positive?
71
+
72
+ { left: left, top: top, width: width, height: height }
73
+ rescue ArgumentError, TypeError
74
+ nil
75
+ end
76
+
77
+ def default_path
78
+ config_home = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
79
+ File.join(config_home, 'qtimetrap', 'config.yml')
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ begin
7
+ require 'timetrap'
8
+ rescue LoadError
9
+ # CLI fallback is supported.
10
+ end
11
+
12
+ module QTimetrap
13
+ module Services
14
+ # Integrates with Timetrap via Ruby API or CLI fallback.
15
+ class TimetrapGateway
16
+ include TimetrapGatewayStartHelpers
17
+ include TimetrapGatewayUpdateTaskHelpers
18
+ include TimetrapGatewayUpdateTimeHelpers
19
+ include TimetrapGatewayUpdateNoteHelpers
20
+ include TimetrapGatewayQueryHelpers
21
+
22
+ def initialize(bin: ENV.fetch('TIMETRAP_BIN', 't'), logger: TimetrapGatewayLogger.new)
23
+ @bin = bin
24
+ @logger = logger
25
+ end
26
+
27
+ def entries
28
+ if api_available?
29
+ logger.log_api(operation: 'entries', input: {}, output: 'requested')
30
+ result = entries_from_api
31
+ logger.log_api(operation: 'entries', input: {}, output: { count: result.size })
32
+ return result
33
+ end
34
+
35
+ entries_from_cli
36
+ end
37
+
38
+ def active_started_at
39
+ if api_available?
40
+ logger.log_api(operation: 'active_started_at', input: {}, output: 'requested')
41
+ result = active_started_at_from_api
42
+ logger.log_api(operation: 'active_started_at', input: {}, output: result&.iso8601)
43
+ return result
44
+ end
45
+
46
+ _active, started_at = active_from_cli
47
+ started_at
48
+ end
49
+
50
+ def start(sheet, checkin_note = nil)
51
+ normalized_sheet, normalized_checkin_note = normalize_start_inputs(sheet, checkin_note)
52
+ return if normalized_sheet.empty?
53
+
54
+ if api_available?
55
+ logger.log_api(
56
+ operation: 'start',
57
+ input: { sheet: normalized_sheet, checkin_note: normalized_checkin_note },
58
+ output: 'requested'
59
+ )
60
+ result = start_via_api(normalized_sheet, normalized_checkin_note)
61
+ logger.log_api(operation: 'start', input: {}, output: result.to_s)
62
+ return result
63
+ end
64
+
65
+ start_via_cli(normalized_sheet, normalized_checkin_note)
66
+ end
67
+
68
+ def stop
69
+ if api_available?
70
+ logger.log_api(operation: 'stop', input: {}, output: 'requested')
71
+ active = Timetrap::Timer.active_entry
72
+ Timetrap::Timer.stop(active) if active
73
+ logger.log_api(operation: 'stop', input: {}, output: active ? 'stopped' : 'noop')
74
+ return
75
+ end
76
+
77
+ run('out')
78
+ end
79
+
80
+ private
81
+
82
+ def api_available?
83
+ defined?(Timetrap::Entry) && defined?(Timetrap::Timer)
84
+ end
85
+
86
+ attr_reader :bin, :logger
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module QTimetrap
8
+ module Services
9
+ # Writes Timetrap gateway input/output events to local log file.
10
+ class TimetrapGatewayLogger
11
+ DEFAULT_LOG_PATH = File.join(Dir.home, '.local', 'log', 'qtimetrap', 'timetrap_gateway.log')
12
+
13
+ def initialize(path: DEFAULT_LOG_PATH)
14
+ @path = path
15
+ end
16
+
17
+ def log_cli(bin:, args:, success:, output:)
18
+ write(
19
+ kind: 'cli',
20
+ bin: bin.to_s,
21
+ input: Array(args).map(&:to_s),
22
+ success: success ? true : false,
23
+ output: output.to_s
24
+ )
25
+ end
26
+
27
+ def log_api(operation:, input:, output:, success: true)
28
+ write(
29
+ kind: 'api',
30
+ operation: operation.to_s,
31
+ input: input,
32
+ success: success ? true : false,
33
+ output: output
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :path
40
+
41
+ def write(payload)
42
+ ensure_log_dir!
43
+ line = JSON.generate(payload.merge(at: Time.now.utc.iso8601))
44
+ File.open(path, 'a') { |file| file.puts(line) }
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def ensure_log_dir!
50
+ FileUtils.mkdir_p(File.dirname(path))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Services
5
+ # Query/run helpers for TimetrapGateway CLI and API data reads.
6
+ module TimetrapGatewayQueryHelpers
7
+ private
8
+
9
+ def entries_from_api
10
+ Timetrap::Entry.order(:start).all.map do |entry|
11
+ Models::TimeEntry.new(
12
+ id: entry.id,
13
+ note: entry.note,
14
+ sheet: entry.sheet,
15
+ start_time: entry[:start],
16
+ end_time: entry[:end]
17
+ )
18
+ end
19
+ end
20
+
21
+ def entries_from_cli
22
+ ok, output = run('display', '--format', 'json')
23
+ return [] unless ok
24
+
25
+ output = normalize_text(output)
26
+ rows = parse_rows(output)
27
+ return [] unless rows
28
+
29
+ rows.map { |row| build_entry(row) }
30
+ rescue JSON::ParserError
31
+ []
32
+ end
33
+
34
+ def active_started_at_from_api
35
+ active = Timetrap::Timer.active_entry
36
+ active ? active[:start] : nil
37
+ end
38
+
39
+ def active_from_cli
40
+ ok, output = run('now')
41
+ return [nil, nil] unless ok
42
+
43
+ output = normalize_text(output)
44
+ match = output.match(/(\d{4}-\d{2}-\d{2} [0-9:]+ [+-]\d{4})/)
45
+ [true, (match ? parse_time(match[1]) : nil)]
46
+ end
47
+
48
+ def run(*args)
49
+ normalized_args = normalize_command_args(args)
50
+ output, status = with_unbundled_env { Open3.capture2e(bin, *normalized_args) }
51
+ log_cli_result(args: normalized_args, success: status.success?, output: output)
52
+ rescue Errno::ENOENT
53
+ log_cli_result(args: args, success: false, output: "Command not found: #{bin}")
54
+ rescue StandardError => e
55
+ log_cli_result(args: args, success: false, output: "#{e.class}: #{e.message}")
56
+ end
57
+
58
+ def normalize_command_args(args)
59
+ args.map { |arg| arg.is_a?(String) ? normalize_text(arg) : arg }
60
+ end
61
+
62
+ def log_cli_result(args:, success:, output:)
63
+ normalized_output = normalize_text(output)
64
+ logger.log_cli(bin: bin, args: args, success: success, output: normalized_output)
65
+ [success, normalized_output]
66
+ end
67
+
68
+ def parse_time(value)
69
+ Time.parse(value)
70
+ rescue ArgumentError, TypeError
71
+ nil
72
+ end
73
+
74
+ def parse_rows(output)
75
+ rows = JSON.parse(output)
76
+ rows.is_a?(Array) ? rows : nil
77
+ end
78
+
79
+ def build_entry(row)
80
+ Models::TimeEntry.new(
81
+ id: row['id'],
82
+ note: row['note'],
83
+ sheet: row['sheet'],
84
+ start_time: parse_time(row['start']),
85
+ end_time: parse_time(row['end'])
86
+ )
87
+ end
88
+
89
+ def normalize_text(value)
90
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').scrub('')
91
+ end
92
+
93
+ def with_unbundled_env
94
+ return yield unless defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
95
+
96
+ Bundler.with_unbundled_env do
97
+ return yield
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end