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.
- checksums.yaml +7 -0
- data/LICENSE +25 -0
- data/README.md +92 -0
- data/Rakefile +105 -0
- data/app/assets/icons/qtimetrap-icon-128.png +0 -0
- data/app/assets/icons/qtimetrap-icon-256.png +0 -0
- data/app/assets/icons/qtimetrap-icon-512.png +0 -0
- data/app/assets/icons/qtimetrap-icon.svg +123 -0
- data/app/components/entries/branch_hierarchy_helpers.rb +41 -0
- data/app/components/entries/leaf_archive_helpers.rb +23 -0
- data/app/components/entries/leaf_note_helpers.rb +118 -0
- data/app/components/entries/leaf_task_helpers.rb +81 -0
- data/app/components/entries/leaf_time_helpers.rb +105 -0
- data/app/components/entries/list_component.rb +118 -0
- data/app/components/entries/list_host_helpers.rb +27 -0
- data/app/components/entries/list_state_helpers.rb +59 -0
- data/app/components/entries/node_presentation_helpers.rb +29 -0
- data/app/components/entries/render_helpers.rb +25 -0
- data/app/components/entries/tree_helpers.rb +133 -0
- data/app/components/entries/tree_toolbar_helpers.rb +98 -0
- data/app/components/project_sidebar/archive_toggle_helpers.rb +22 -0
- data/app/components/project_sidebar/component.rb +125 -0
- data/app/components/project_sidebar/logo_helpers.rb +53 -0
- data/app/components/project_sidebar/project_button_helpers.rb +72 -0
- data/app/components/project_sidebar/project_selection_helpers.rb +94 -0
- data/app/components/project_sidebar/task_helpers.rb +94 -0
- data/app/components/project_sidebar/task_selection_helpers.rb +72 -0
- data/app/components/qt_ui_helpers.rb +27 -0
- data/app/components/tracker_controls/component.rb +80 -0
- data/app/components/tracker_controls/layout_builder.rb +100 -0
- data/app/components/tracker_controls/layout_helpers.rb +88 -0
- data/app/models/null_settings_store.rb +12 -0
- data/app/models/time_entry.rb +59 -0
- data/app/services/archived_entries_store.rb +71 -0
- data/app/services/formatters.rb +29 -0
- data/app/services/settings_store.rb +83 -0
- data/app/services/timetrap_gateway.rb +89 -0
- data/app/services/timetrap_gateway_logger.rb +54 -0
- data/app/services/timetrap_gateway_query_helpers.rb +102 -0
- data/app/services/timetrap_gateway_start_helpers.rb +28 -0
- data/app/services/timetrap_gateway_update_note_helpers.rb +48 -0
- data/app/services/timetrap_gateway_update_task_helpers.rb +47 -0
- data/app/services/timetrap_gateway_update_time_helpers.rb +50 -0
- data/app/styles/theme.rb +61 -0
- data/app/styles/themes/dark/application.qss +8 -0
- data/app/styles/themes/dark/entries_list.qss +235 -0
- data/app/styles/themes/dark/project_sidebar.qss +99 -0
- data/app/styles/themes/dark/snippets/app_background.qss +2 -0
- data/app/styles/themes/dark/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/dark/snippets/button_start.qss +5 -0
- data/app/styles/themes/dark/snippets/button_stop.qss +5 -0
- data/app/styles/themes/dark/snippets/entries_host.qss +2 -0
- data/app/styles/themes/dark/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/dark/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/dark/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/dark/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/dark/snippets/project_button.qss +5 -0
- data/app/styles/themes/dark/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/dark/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/dark/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/dark/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/dark/tracker_controls.qss +84 -0
- data/app/styles/themes/light/application.qss +8 -0
- data/app/styles/themes/light/entries_list.qss +235 -0
- data/app/styles/themes/light/project_sidebar.qss +99 -0
- data/app/styles/themes/light/snippets/app_background.qss +2 -0
- data/app/styles/themes/light/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/light/snippets/button_start.qss +5 -0
- data/app/styles/themes/light/snippets/button_stop.qss +5 -0
- data/app/styles/themes/light/snippets/entries_host.qss +2 -0
- data/app/styles/themes/light/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/light/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/light/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/light/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/light/snippets/project_button.qss +5 -0
- data/app/styles/themes/light/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/light/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/light/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/light/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/light/tracker_controls.qss +84 -0
- data/app/view_models/entry_nodes_builder.rb +109 -0
- data/app/view_models/main_view_model.rb +135 -0
- data/app/view_models/main_view_model_archive_mode_helpers.rb +102 -0
- data/app/view_models/main_view_model_entry_note_helpers.rb +30 -0
- data/app/view_models/main_view_model_entry_task_helpers.rb +39 -0
- data/app/view_models/main_view_model_entry_time_helpers.rb +45 -0
- data/app/view_models/main_view_model_sheet_helpers.rb +74 -0
- data/app/view_models/main_view_model_task_filter_helpers.rb +58 -0
- data/app/view_models/main_view_model_time_range_filter_helpers.rb +23 -0
- data/app/views/main_window.rb +126 -0
- data/app/views/main_window_layout_builder.rb +114 -0
- data/app/views/main_window_runtime.rb +123 -0
- data/app/views/main_window_runtime_archive_helpers.rb +26 -0
- data/app/views/main_window_runtime_entry_task_helpers.rb +21 -0
- data/app/views/main_window_runtime_entry_time_helpers.rb +17 -0
- data/app/views/main_window_runtime_key_helpers.rb +55 -0
- data/app/views/main_window_runtime_render_helpers.rb +50 -0
- data/app/views/main_window_splitter_toggle_bootstrap_helpers.rb +34 -0
- data/app/views/main_window_splitter_toggle_helpers.rb +103 -0
- data/app/views/main_window_splitter_toggle_hover_helpers.rb +71 -0
- data/app/views/main_window_splitter_toggle_layout_helpers.rb +24 -0
- data/app/views/main_window_ui_helpers.rb +45 -0
- data/app/views/window_icon_loader.rb +37 -0
- data/bin/qtimetrap +7 -0
- data/config/application.rb +87 -0
- data/config/environments/development.rb +5 -0
- data/config/environments/production.rb +5 -0
- data/config/initializers/theme.rb +5 -0
- data/config/initializers/timetrap.rb +5 -0
- data/lib/qtimetrap/configuration.rb +15 -0
- data/lib/qtimetrap/container.rb +57 -0
- data/lib/qtimetrap/version.rb +5 -0
- data/lib/qtimetrap.rb +25 -0
- 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
|