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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Builds hierarchical week/day/project/entry nodes for entries list UI.
6
+ class EntryNodesBuilder
7
+ def initialize(entries:, selected_project:)
8
+ @entries = entries
9
+ @selected_project = selected_project
10
+ end
11
+
12
+ def build
13
+ nodes = week_groups.map { |week_start, entries| week_node(week_start, entries) }
14
+ nodes.empty? ? [empty_node] : nodes
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :entries, :selected_project
20
+
21
+ def week_groups
22
+ entries.group_by { |entry| week_start_for(entry.day) }.sort_by { |week_start, _| week_start }.reverse
23
+ end
24
+
25
+ def week_node(week_start, week_entries)
26
+ total = Services::Formatters.seconds_to_hms(week_entries.sum(&:duration_seconds))
27
+ week_end = week_start + 6
28
+ {
29
+ id: "week:#{week_start}",
30
+ type: :week,
31
+ label: "Week #{week_start.strftime('%b %-d')} - #{week_end.strftime('%b %-d')} Total: #{total}",
32
+ children: day_nodes(week_entries)
33
+ }
34
+ end
35
+
36
+ def day_nodes(week_entries)
37
+ week_entries.group_by(&:day).keys.sort.reverse.map do |day|
38
+ day_entries = week_entries.select { |entry| entry.day == day }
39
+ total = Services::Formatters.seconds_to_hms(day_entries.sum(&:duration_seconds))
40
+ {
41
+ id: "day:#{day}",
42
+ type: :day,
43
+ label: "#{day.strftime('%a, %b %-d')} Total: #{total}",
44
+ children: project_nodes(day, day_entries)
45
+ }
46
+ end
47
+ end
48
+
49
+ def project_nodes(day, day_entries)
50
+ grouped = day_entries.group_by { |entry| [entry.project, entry.task] }
51
+ grouped
52
+ .sort_by { |_key, items| latest_start_time(items) }
53
+ .reverse
54
+ .map do |(project, task), items|
55
+ total = Services::Formatters.seconds_to_hms(items.sum(&:duration_seconds))
56
+ {
57
+ id: "project:#{day}:#{project}:#{task}",
58
+ type: :project,
59
+ label: "#{project} | #{task} (#{items.size}) #{total}",
60
+ children: entry_detail_nodes(items)
61
+ }
62
+ end
63
+ end
64
+
65
+ def entry_detail_nodes(items)
66
+ items.sort_by { |entry| entry.start_time || Time.at(0) }.reverse.each_with_index.map do |entry, index|
67
+ entry_node(entry, index)
68
+ end
69
+ end
70
+
71
+ def entry_node(entry, index)
72
+ note = entry.note.strip
73
+ display_note = note.empty? ? '(no note)' : note
74
+ start_label, end_label = Services::Formatters.time_bounds(entry)
75
+ duration = Services::Formatters.seconds_to_hms(entry.duration_seconds)
76
+ {
77
+ id: "entry:#{entry.id || index}",
78
+ type: :entry,
79
+ entry_id: entry.id || index,
80
+ project_name: entry.project,
81
+ task_name: entry.task.to_s,
82
+ start_label: start_label,
83
+ end_label: end_label,
84
+ prefix: duration,
85
+ note: note,
86
+ label: "#{start_label} - #{end_label} #{duration} #{display_note}",
87
+ children: []
88
+ }
89
+ end
90
+
91
+ def latest_start_time(items)
92
+ items.map { |entry| entry.start_time || Time.at(0) }.max
93
+ end
94
+
95
+ def empty_node
96
+ {
97
+ id: "empty:#{selected_project}",
98
+ type: :empty,
99
+ label: "No entries for filter: #{selected_project}",
100
+ children: []
101
+ }
102
+ end
103
+
104
+ def week_start_for(day)
105
+ day - ((day.wday + 6) % 7)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module QTimetrap
6
+ module ViewModels
7
+ # Coordinates tracker data and exposes presentation-ready state for UI.
8
+ class MainViewModel
9
+ include MainViewModelEntryTaskHelpers
10
+ include MainViewModelEntryNoteHelpers
11
+ include MainViewModelEntryTimeHelpers
12
+ include MainViewModelSheetHelpers
13
+ include MainViewModelTaskFilterHelpers
14
+ include MainViewModelTimeRangeFilterHelpers
15
+ include MainViewModelArchiveModeHelpers
16
+
17
+ EPOCH_TIME = Time.at(0)
18
+
19
+ attr_reader :selected_project, :selected_projects, :selected_tasks, :entries, :current_started_at, :current_sheet,
20
+ :time_filter_from_at, :time_filter_to_at
21
+
22
+ def initialize(gateway: Services::TimetrapGateway.new, archived_entries_store: Services::ArchivedEntriesStore.new)
23
+ @gateway = gateway
24
+ @archived_entries_store = archived_entries_store
25
+ initialize_state
26
+ end
27
+
28
+ def refresh!
29
+ @current_started_at = gateway.active_started_at
30
+ @entries = gateway.entries
31
+ @current_sheet = detect_current_sheet
32
+ reset_archive_mode_caches!
33
+ normalize_selected_projects!
34
+ seed_current_fields_from_sheet!
35
+ normalize_selected_tasks!
36
+ self
37
+ end
38
+
39
+ def select_project(project, sync_current_fields: true)
40
+ select_projects([project], primary_project: project, sync_current_fields: sync_current_fields)
41
+ end
42
+
43
+ def select_projects(projects, primary_project:, sync_current_fields: true)
44
+ normalized = Array(projects).map(&:to_s).reject(&:empty?).uniq
45
+ normalized = ['* ALL'] if normalized.empty? || normalized.include?('* ALL')
46
+ @selected_projects = normalized
47
+ @selected_project = normalized.include?(primary_project) ? primary_project : normalized.first
48
+ @selected_tasks = []
49
+ return unless sync_current_fields
50
+
51
+ apply_selected_project_to_current_field!
52
+ self.current_task_input = latest_task_for_project(@selected_project)
53
+ end
54
+
55
+ def start_tracking(sheet)
56
+ value = normalize_text(sheet).strip
57
+ raise ArgumentError, 'Task is required' if value.empty?
58
+
59
+ gateway.start(value)
60
+ @current_started_at = Time.now unless current_started_at
61
+ end
62
+
63
+ def stop_tracking
64
+ gateway.stop
65
+ @current_started_at = nil
66
+ end
67
+
68
+ def week_total_seconds
69
+ start_of_week = Date.today - ((Date.today.wday + 6) % 7)
70
+ filtered_entries.sum do |entry|
71
+ entry.day >= start_of_week ? entry.duration_seconds : 0
72
+ end
73
+ end
74
+
75
+ def total_seconds
76
+ filtered_entries.sum(&:duration_seconds)
77
+ end
78
+
79
+ def summary_line
80
+ "Week total: #{Services::Formatters.seconds_to_hms(week_total_seconds)} | " \
81
+ "Total: #{Services::Formatters.seconds_to_hms(total_seconds)}"
82
+ end
83
+
84
+ def running_timer_line(now: Time.now)
85
+ return '00:00:00' unless current_started_at
86
+
87
+ Services::Formatters.seconds_to_hms(now.to_i - current_started_at.to_i)
88
+ end
89
+
90
+ def running_current_sheet? = !current_started_at.nil?
91
+
92
+ def entry_nodes
93
+ EntryNodesBuilder.new(entries: filtered_entries, selected_project: selected_project).build
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :gateway, :archived_entries_store
99
+
100
+ def initialize_state
101
+ @selected_project = '* ALL'
102
+ @selected_projects = ['* ALL']
103
+ @selected_tasks = []
104
+ @entries = []
105
+ @current_started_at = nil
106
+ @current_sheet = nil
107
+ @time_filter_from_at = nil
108
+ @time_filter_to_at = nil
109
+ @archive_mode = false
110
+ reset_archive_mode_caches!
111
+ end
112
+
113
+ def detect_current_sheet
114
+ running_sheet || latest_sheet
115
+ end
116
+
117
+ def running_sheet = newest_entry(entries.select(&:running?))&.sheet
118
+
119
+ def latest_sheet = newest_entry(entries)&.sheet
120
+
121
+ def newest_entry(collection)
122
+ collection.max_by { |entry| entry.start_time || EPOCH_TIME }
123
+ end
124
+
125
+ def latest_task_for_project(project)
126
+ return '' if project == '* ALL'
127
+
128
+ entry = newest_entry(entries.select { |item| item.project == project })
129
+ entry ? entry.task.to_s : ''
130
+ end
131
+
132
+ def normalize_text(value) = value.to_s
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Archive-mode specific filtering and projections for MainViewModel.
6
+ module MainViewModelArchiveModeHelpers
7
+ EMPTY_ARRAY = [].freeze
8
+
9
+ def project_names
10
+ ['* ALL', *ordered_project_names_for_mode]
11
+ end
12
+
13
+ def task_names_for_selected_project
14
+ return [] unless selected_projects == [selected_project]
15
+ return [] if selected_project == '* ALL'
16
+
17
+ task_names_for_project(selected_project)
18
+ end
19
+
20
+ def task_names_for_project(project)
21
+ normalized_project = normalize_text(project).strip
22
+ return [] if normalized_project.empty? || normalized_project == '* ALL'
23
+
24
+ ordered_task_names_by_project.fetch(normalized_project, EMPTY_ARRAY)
25
+ end
26
+
27
+ def archive_mode?
28
+ @archive_mode
29
+ end
30
+
31
+ def archive_mode=(enabled)
32
+ @archive_mode = [true, 1].include?(enabled)
33
+ reset_archive_mode_caches!
34
+ normalize_selected_projects!
35
+ normalize_selected_tasks!
36
+ end
37
+
38
+ def archive_entry(entry_id)
39
+ archived_entries_store.archive(entry_id)
40
+ reset_archive_mode_caches!
41
+ end
42
+
43
+ def unarchive_entry(entry_id)
44
+ archived_entries_store.unarchive(entry_id)
45
+ reset_archive_mode_caches!
46
+ end
47
+
48
+ private
49
+
50
+ def entries_for_mode
51
+ entries.select { |entry| archived_entries_store.archived?(entry.id) == archive_mode? }
52
+ end
53
+
54
+ def ordered_project_names_for_mode
55
+ @ordered_project_names_for_mode ||= ordered_project_names(entries_for_mode)
56
+ end
57
+
58
+ def ordered_task_names_by_project
59
+ @ordered_task_names_by_project ||= build_ordered_task_names_by_project
60
+ end
61
+
62
+ def ordered_project_names(collection)
63
+ collection
64
+ .group_by(&:project)
65
+ .sort_by { |project, project_entries| sort_key(project, project_entries) }
66
+ .map(&:first)
67
+ end
68
+
69
+ def ordered_task_names(collection)
70
+ collection
71
+ .group_by { |entry| entry.task.to_s }
72
+ .reject { |task, _| task.empty? }
73
+ .sort_by { |task, task_entries| sort_key(task, task_entries) }
74
+ .map(&:first)
75
+ end
76
+
77
+ def sort_key(name, grouped_entries)
78
+ newest_started_at = newest_entry(grouped_entries)&.start_time || EPOCH_TIME
79
+ [-newest_started_at.to_i, name]
80
+ end
81
+
82
+ def build_ordered_task_names_by_project
83
+ entries_for_mode
84
+ .group_by(&:project)
85
+ .transform_values { |project_entries| ordered_task_names(project_entries) }
86
+ end
87
+
88
+ def normalize_selected_projects!
89
+ available = project_names
90
+ normalized = Array(@selected_projects).map(&:to_s).reject(&:empty?).uniq & available
91
+ normalized = ['* ALL'] if normalized.empty? || normalized.include?('* ALL')
92
+ @selected_projects = normalized
93
+ @selected_project = normalized.include?(@selected_project) ? @selected_project : normalized.first
94
+ end
95
+
96
+ def reset_archive_mode_caches!
97
+ @ordered_project_names_for_mode = nil
98
+ @ordered_task_names_by_project = nil
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Entry note update behavior for MainViewModel.
6
+ module MainViewModelEntryNoteHelpers
7
+ def update_entry_note(entry_id, note)
8
+ normalized_id = entry_id.to_i
9
+ return if normalized_id.zero?
10
+
11
+ gateway.update_note(normalized_id, note.to_s)
12
+ @entries = entries.map { |entry| updated_entry_with_note(entry, normalized_id, note.to_s) }
13
+ end
14
+
15
+ private
16
+
17
+ def updated_entry_with_note(entry, entry_id, note)
18
+ return entry unless entry.id.to_i == entry_id
19
+
20
+ Models::TimeEntry.new(
21
+ id: entry.id,
22
+ note: note,
23
+ sheet: entry.sheet,
24
+ start_time: entry.start_time,
25
+ end_time: entry.end_time
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Entry task move behavior for MainViewModel.
6
+ module MainViewModelEntryTaskHelpers
7
+ def update_entry_task(entry_id, task_name)
8
+ normalized_id = entry_id.to_i
9
+ return if normalized_id.zero?
10
+
11
+ entry = find_entry_for_task_update(normalized_id)
12
+ normalized_task = normalize_target_task(task_name)
13
+ gateway.update_task(normalized_id, target_sheet_for_entry(entry, normalized_task))
14
+ self.current_task_input = normalized_task if entry.running?
15
+ refresh!
16
+ end
17
+
18
+ private
19
+
20
+ def find_entry_for_task_update(entry_id)
21
+ entry = entries.find { |item| item.id.to_i == entry_id }
22
+ raise ArgumentError, "Entry not found: #{entry_id}" unless entry
23
+
24
+ entry
25
+ end
26
+
27
+ def normalize_target_task(task_name)
28
+ value = normalize_text(task_name).strip
29
+ raise ArgumentError, 'Task is required' if value.empty?
30
+
31
+ value
32
+ end
33
+
34
+ def target_sheet_for_entry(entry, task_name)
35
+ "#{entry.project}|#{task_name}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Entry time update behavior for MainViewModel.
6
+ module MainViewModelEntryTimeHelpers
7
+ def update_entry_time(entry_id, start_text, end_text)
8
+ normalized_id = entry_id.to_i
9
+ return if normalized_id.zero?
10
+
11
+ entry = entries.find { |item| item.id.to_i == normalized_id }
12
+ raise ArgumentError, "Entry not found: #{normalized_id}" unless entry
13
+
14
+ start_time = parse_entry_clock_value(entry.start_time, start_text)
15
+ end_time = parse_entry_clock_value(entry.end_time || entry.start_time, end_text)
16
+
17
+ gateway.update_time(normalized_id, start_time: start_time, end_time: end_time)
18
+ refresh!
19
+ end
20
+
21
+ private
22
+
23
+ def parse_entry_clock_value(reference_time, value)
24
+ text = value.to_s.strip
25
+ return nil if text.empty? || text == 'running'
26
+
27
+ hour, min = parse_clock_components(text, value)
28
+
29
+ base = reference_time || Time.now
30
+ Time.new(base.year, base.month, base.day, hour, min, 0, base.utc_offset)
31
+ end
32
+
33
+ def parse_clock_components(text, original_value)
34
+ match = text.match(/\A(\d{1,2}):(\d{2})\z/)
35
+ raise ArgumentError, "Invalid time value: #{original_value}" unless match
36
+
37
+ hour = match[1].to_i
38
+ min = match[2].to_i
39
+ raise ArgumentError, "Invalid time value: #{original_value}" if hour > 23 || min > 59
40
+
41
+ [hour, min]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Current sheet/project/task presentation and composition helpers.
6
+ module MainViewModelSheetHelpers
7
+ def current_sheet_label(*)
8
+ current_project_name
9
+ end
10
+
11
+ def current_sheet_input
12
+ current_task_input
13
+ end
14
+
15
+ def current_project_name
16
+ @current_project_name || project_from_sheet.to_s
17
+ end
18
+
19
+ def current_task_input
20
+ @current_task_input || task_from_sheet.to_s
21
+ end
22
+
23
+ def current_task_input=(task_name)
24
+ @current_task_input = normalize_text(task_name).strip
25
+ end
26
+
27
+ def current_project_name=(project_name)
28
+ @current_project_name = normalize_text(project_name).strip
29
+ end
30
+
31
+ def sheet_for_task_input(task_input)
32
+ project = current_project_name
33
+ project = selected_project if project.to_s.empty? && selected_project != '* ALL'
34
+ project = normalize_text(project).strip
35
+ task = normalize_text(task_input).strip
36
+ raise ArgumentError, 'Project is required' if project.empty? || project == '* ALL'
37
+ raise ArgumentError, 'Task is required' if task.empty?
38
+
39
+ "#{project}|#{task}"
40
+ end
41
+
42
+ private
43
+
44
+ def project_from_sheet
45
+ split_current_sheet&.first
46
+ end
47
+
48
+ def task_from_sheet
49
+ split_current_sheet&.last.to_s
50
+ end
51
+
52
+ def split_current_sheet
53
+ raw = current_sheet.to_s.strip
54
+ return nil if raw.empty?
55
+
56
+ if raw.include?('|')
57
+ project, task = raw.split('|', 2).map(&:strip)
58
+ return [project, task]
59
+ end
60
+
61
+ [raw, '']
62
+ end
63
+
64
+ def seed_current_fields_from_sheet!
65
+ @current_project_name = project_from_sheet.to_s if @current_project_name.nil?
66
+ @current_task_input = task_from_sheet.to_s if @current_task_input.nil?
67
+ end
68
+
69
+ def apply_selected_project_to_current_field!
70
+ @current_project_name = selected_project == '* ALL' ? project_from_sheet.to_s : selected_project.to_s
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Task filter behavior for MainViewModel.
6
+ module MainViewModelTaskFilterHelpers
7
+ def select_tasks(tasks)
8
+ normalized = Array(tasks).map(&:to_s).reject(&:empty?).uniq
9
+ @selected_tasks = task_filter_disabled? ? [] : (normalized & task_names_for_selected_project)
10
+ end
11
+
12
+ def filtered_entries
13
+ scoped = entries_for_selected_project
14
+ scoped = apply_selected_tasks_filter(scoped)
15
+ apply_time_range_filter(scoped)
16
+ end
17
+
18
+ private
19
+
20
+ def entries_for_selected_project
21
+ return entries_for_mode if selected_projects == ['* ALL']
22
+
23
+ entries_for_mode.select { |entry| selected_projects.include?(entry.project) }
24
+ end
25
+
26
+ def apply_selected_tasks_filter(scoped)
27
+ return scoped if selected_tasks.empty?
28
+
29
+ scoped.select { |entry| selected_tasks.include?(entry.task.to_s) }
30
+ end
31
+
32
+ def apply_time_range_filter(scoped)
33
+ return scoped if @time_filter_from_at.nil? && @time_filter_to_at.nil?
34
+
35
+ scoped.select { |entry| entry_in_selected_time_range?(entry) }
36
+ end
37
+
38
+ def entry_in_selected_time_range?(entry)
39
+ start_at = entry.start_time
40
+ return false unless start_at
41
+ return false if @time_filter_from_at && start_at < @time_filter_from_at
42
+ return false if @time_filter_to_at && start_at > @time_filter_to_at
43
+
44
+ true
45
+ end
46
+
47
+ def normalize_selected_tasks!
48
+ return @selected_tasks = [] if task_filter_disabled?
49
+
50
+ @selected_tasks &= task_names_for_selected_project
51
+ end
52
+
53
+ def task_filter_disabled?
54
+ selected_projects != [selected_project] || selected_project == '* ALL'
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ViewModels
5
+ # Date-time range filter behavior for MainViewModel.
6
+ module MainViewModelTimeRangeFilterHelpers
7
+ def update_time_range_filter(from_at:, to_at:)
8
+ validate_time_range!(from_at, to_at)
9
+ @time_filter_from_at = from_at
10
+ @time_filter_to_at = to_at
11
+ end
12
+
13
+ private
14
+
15
+ def validate_time_range!(from_at, to_at)
16
+ return unless from_at && to_at
17
+ return unless from_at > to_at
18
+
19
+ raise ArgumentError, 'Date-time filter FROM must be less than or equal to TO'
20
+ end
21
+ end
22
+ end
23
+ end