timet 1.5.3 → 1.5.5

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: f65773f17ed00b4aff9ff1a3157191f8753686755b6ffd2e985bde5927783c99
4
- data.tar.gz: b3f6474b4db6fc4f3ebed7b0d976c46061cfc29c85461c6f437a16f48c824aa5
3
+ metadata.gz: bc6553610acca8f30eb067accd9a8ff09756ea7c0fdb231dac896210018e0db1
4
+ data.tar.gz: 41e673fa16e1a7ea7d7c7c3f75a7131c7add0fa4ea297ae3044ca9ddbbd1b4f3
5
5
  SHA512:
6
- metadata.gz: a0db80d8b3671e64d6afc2d1164c74567fedb707a83493042ffb1e3cc87f8242580442e1ad21afc7fb1fd567c1e0b70de96a4647edcc6aef47f44406790d1a5e
7
- data.tar.gz: 394458e551db981b18b6c1e72a9fa0b734031e6c8408f70f1fd7d2498a98e292204eca0835374b03a7e51df47d0fcc63266d696a9744863a31e153210a3e26d1
6
+ metadata.gz: '01793d4487cd53b78a2d35721a3e6d91ff29a8c98976e9e861bfc599e49dfbf89939bec66da4fd986f8b8f99c4cf2001ac859274859185aaf4a89f9f56bae7a3'
7
+ data.tar.gz: c0f27b5b4656c07a16e861544daf610b94d397a5b245e89d65850c23e24f1a5318042b6871430cd2c0341c0bdb3f27b4163781bbf024680157cd68889202459c
data/.rubocop.yml CHANGED
@@ -8,4 +8,13 @@ AllCops:
8
8
  Metrics/MethodLength:
9
9
  Max: 12
10
10
 
11
- require: rubocop-rspec
11
+ plugins: rubocop-rspec
12
+
13
+ RSpec/ExampleLength:
14
+ Max: 15
15
+
16
+ Metrics/CyclomaticComplexity:
17
+ Max: 8
18
+
19
+ RSpec/MultipleMemoizedHelpers:
20
+ Max: 15
data/CHANGELOG.md CHANGED
@@ -1,4 +1,51 @@
1
- ## [Unreleased]
1
+ ## [1.5.5] - 2025-02-26
2
+
3
+ **Improvements:**
4
+
5
+ - Refactored `DatabaseSyncer` specs to improve clarity, maintainability, and reduce redundancy.
6
+ - Removed `#process_existing_item` as its logic was redundant.
7
+ - Improved `#remote_wins?` tests with `let` blocks and better descriptions.
8
+ - Split multi-expectation tests into smaller, focused tests.
9
+ - Improved test descriptions for accuracy and clarity.
10
+ - Reorganized tests into more logical `describe` blocks.
11
+ - Moved S3 download tests to the `Timet::S3Supabase` `describe` block.
12
+ - Improved CSV export tests with extracted common data, centralized database setup, and a helper method.
13
+ - Improved message expectations in `#export_report` using `class_spy` and `have_received`.
14
+ - Improved test readability in `ApplicationHelper` with `let` blocks and separate `it` blocks.
15
+ - Refactored `DatabaseSyncer` specs with `let` blocks, focused examples, and explicit `expect` assertions.
16
+ - Prefer `have_received` for setting message expectations.
17
+ - Improved granularity in specs with smaller, focused examples and shared test data.
18
+ - Updated gem dependencies to latest versions.
19
+ - Overall code cleanup and improved test structure.
20
+ - Update the way rubocop plugins are defined in the config.
21
+ - Removed unnecessary comments.
22
+
23
+ **Bug Fixes:**
24
+
25
+ - Fixed a bug in the time field update logic in `validation_edit_helper`, specifically regarding the end time.
26
+
27
+ ## [1.5.4] - 2025-02-11
28
+
29
+ **Improvements:**
30
+
31
+ - Added `.env` file creation in CI workflow for testing environment variables.
32
+ - Updated Code Climate coverage reporting to use `simplecov-lcov` for LCOV format compatibility.
33
+ - Refactored validation error message tests in `ValidationEditHelper` for clarity and maintainability.
34
+ - Simplified environment variable validation in `S3Supabase` by introducing a helper method `check_env_var`.
35
+ - Improved integration tests for `play_sound_and_notify` with better stubbing and platform-specific behavior checks.
36
+ - Added comprehensive tests for `#process_existing_item`, `#update_item_from_hash`, and `#insert_item_from_hash` in `DatabaseSyncer`.
37
+ - Enhanced database synchronization logic and moved `ITEM_FIELDS` to `DatabaseSyncer` for better organization.
38
+ - Added `.format_time_string` method to `TimeHelper` with tests for various input formats.
39
+ - Updated AWS SDK dependencies and improved database sync logging.
40
+ - Consolidated integration tests and improved readability.
41
+ - Improved error handling and added tests for `S3Supabase`.
42
+
43
+ **Bug Fixes:**
44
+
45
+ - Fixed environment variable validation in `S3Supabase` to handle `nil` values.
46
+ - Resolved issues with database synchronization logic in `DatabaseSyncer`.
47
+ - Fixed test setup and cleanup in `S3Supabase` and `DatabaseSyncer` specs.
48
+ - Addressed edge cases in `#process_and_update_time_field` and `#valid_time_value?`.
2
49
 
3
50
  ## [1.5.3] - 2025-01-02
4
51
 
@@ -52,15 +52,14 @@ module Timet
52
52
  def initialize(*args)
53
53
  super
54
54
 
55
- # Initialize database without validation in test environment
56
55
  if defined?(RSpec)
57
56
  @db = Database.new
58
57
  else
59
- command_name = args[2][:current_command].name
58
+ command_name = args.dig(2, :current_command, :name)
60
59
  if VALID_ARGUMENTS.include?(command_name)
61
60
  @db = Database.new
62
61
  else
63
- puts 'Invalid arguments provided. Please check your input.'
62
+ warn 'Invalid arguments provided. Please check your input.'
64
63
  exit(1)
65
64
  end
66
65
  end
@@ -110,6 +109,7 @@ module Timet
110
109
 
111
110
  desc 'stop', 'Stop time tracking'
112
111
  # Stops the current tracking session if there is one in progress.
112
+ # After stopping the tracking session, it displays a summary of the tracked time.
113
113
  #
114
114
  # @return [void] This method does not return a value; it performs side effects such as updating the tracking item
115
115
  # and generating a summary.
@@ -120,15 +120,14 @@ module Timet
120
120
  # @note The method checks if the last tracking item is in progress by calling `@db.item_status`.
121
121
  # @note If the last item is in progress, it fetches the last item's ID using `@db.fetch_last_id` and updates it
122
122
  # with the current timestamp.
123
- # @note The method then fetches the last item using `@db.last_item` and generates a summary if the result
124
- # is not nil.
125
- def stop(display = nil)
123
+ # @note The method always generates a summary after stopping the tracking session.
124
+ def stop
126
125
  return unless @db.item_status == :in_progress
127
126
 
128
127
  last_id = @db.fetch_last_id
129
128
  @db.update_item(last_id, 'end', TimeHelper.current_timestamp)
130
129
 
131
- summary unless display
130
+ summary
132
131
  end
133
132
 
134
133
  desc 'resume (r) [id]', 'Resume last task (id is an optional parameter) => tt resume'
@@ -306,6 +305,7 @@ module Timet
306
305
  desc 'sync', 'Sync local db with supabase external db'
307
306
  def sync
308
307
  puts 'Syncing database with remote storage...'
308
+ puts 'Sync method called'
309
309
  DatabaseSyncHelper.sync(@db, BUCKET)
310
310
  end
311
311
  end
@@ -111,7 +111,7 @@ module Timet
111
111
  def run_linux_session(time, tag)
112
112
  notification_command = "notify-send --icon=clock '#{show_message(tag)}'"
113
113
  command = "sleep #{time} && tput bel && tt stop 0 && #{notification_command} &"
114
- pid = spawn(command)
114
+ pid = Kernel.spawn(command)
115
115
  Process.detach(pid)
116
116
  end
117
117
 
@@ -123,7 +123,7 @@ module Timet
123
123
  def run_mac_session(time, tag)
124
124
  notification_command = "osascript -e 'display notification \"#{show_message(tag)}\"'"
125
125
  command = "sleep #{time} && afplay /System/Library/Sounds/Basso.aiff && tt stop 0 && #{notification_command} &"
126
- pid = spawn(command)
126
+ pid = Kernel.spawn(command)
127
127
  Process.detach(pid)
128
128
  end
129
129
 
@@ -285,8 +285,6 @@ module Timet
285
285
  :complete
286
286
  end
287
287
 
288
- private
289
-
290
288
  # Moves the old database file to the new location if it exists.
291
289
  #
292
290
  # @param database_path [String] The path to the new SQLite database file.
@@ -314,7 +312,7 @@ module Timet
314
312
  # @raise [StandardError] If there is an issue executing the SQL queries, an error may be raised.
315
313
  #
316
314
  def update_time_columns
317
- result = execute_sql('SELECT * FROM items where updated_at is null or created_at is null')
315
+ result = execute_sql('SELECT * FROM items WHERE updated_at IS NULL OR created_at IS NULL')
318
316
  result.each do |item|
319
317
  id = item[0]
320
318
  end_time = item[2]
@@ -2,13 +2,13 @@
2
2
 
3
3
  require 'tempfile'
4
4
  require 'digest'
5
+ require_relative 'database_syncer'
5
6
 
6
7
  module Timet
7
8
  # Helper module for database synchronization operations
8
9
  # Provides methods for comparing and syncing local and remote databases
9
10
  module DatabaseSyncHelper
10
- # Fields used in item operations
11
- ITEM_FIELDS = %w[start end tag notes pomodoro updated_at created_at deleted].freeze
11
+ extend DatabaseSyncer
12
12
 
13
13
  # Main entry point for database synchronization
14
14
  #
@@ -57,6 +57,8 @@ module Timet
57
57
  # @note This method ensures proper resource cleanup by using ensure block
58
58
  def self.with_temp_file
59
59
  temp_file = Tempfile.new('remote_db')
60
+ raise 'Temporary file path is nil' unless temp_file.path
61
+
60
62
  yield temp_file
61
63
  ensure
62
64
  temp_file.close
@@ -74,209 +76,5 @@ module Timet
74
76
  local_md5 = Digest::MD5.file(local_path).hexdigest
75
77
  remote_md5 == local_md5
76
78
  end
77
-
78
- # Handles the synchronization process when differences are detected between databases
79
- #
80
- # @param local_db [SQLite3::Database] The local database connection
81
- # @param remote_storage [S3Supabase] The remote storage client for cloud operations
82
- # @param bucket [String] The S3 bucket name
83
- # @param local_db_path [String] Path to the local database file
84
- # @param remote_path [String] Path to the downloaded remote database file
85
- # @return [void]
86
- # @note This method attempts to sync the databases and handles any errors that occur during the process
87
- def self.handle_database_differences(*args)
88
- local_db, remote_storage, bucket, local_db_path, remote_path = args
89
- puts 'Differences detected between local and remote databases'
90
- begin
91
- sync_with_remote_database(local_db, remote_path, remote_storage, bucket, local_db_path)
92
- rescue SQLite3::Exception => e
93
- handle_sync_error(e, remote_storage, bucket, local_db_path)
94
- end
95
- end
96
-
97
- # Performs the actual database synchronization by setting up connections and syncing data
98
- #
99
- # @param local_db [SQLite3::Database] The local database connection
100
- # @param remote_path [String] Path to the remote database file
101
- # @param remote_storage [S3Supabase] The remote storage client for cloud operations
102
- # @param bucket [String] The S3 bucket name
103
- # @param local_db_path [String] Path to the local database file
104
- # @return [void]
105
- # @note Configures both databases to return results as hashes for consistent data handling
106
- def self.sync_with_remote_database(*args)
107
- local_db, remote_path, remote_storage, bucket, local_db_path = args
108
- db_remote = open_remote_database(remote_path)
109
- db_remote.results_as_hash = true
110
- local_db.instance_variable_get(:@db).results_as_hash = true
111
- sync_databases(local_db, db_remote, remote_storage, bucket, local_db_path)
112
- end
113
-
114
- # Opens and validates a connection to the remote database
115
- #
116
- # @param remote_path [String] Path to the remote database file
117
- # @return [SQLite3::Database] The initialized database connection
118
- # @raise [RuntimeError] If the database connection cannot be established
119
- # @note Validates that the database connection is properly initialized
120
- def self.open_remote_database(remote_path)
121
- db_remote = SQLite3::Database.new(remote_path)
122
- raise 'Failed to initialize remote database' unless db_remote
123
-
124
- db_remote
125
- end
126
-
127
- # Handles errors that occur during database synchronization
128
- #
129
- # @param error [SQLite3::Exception] The error that occurred during sync
130
- # @param remote_storage [S3Supabase] The remote storage client for cloud operations
131
- # @param bucket [String] The S3 bucket name
132
- # @param local_db_path [String] Path to the local database file
133
- # @return [void]
134
- # @note When sync fails, this method falls back to uploading the local database
135
- def self.handle_sync_error(error, remote_storage, bucket, local_db_path)
136
- puts "Error opening remote database: #{error.message}"
137
- puts 'Uploading local database to replace corrupted remote database'
138
- remote_storage.upload_file(bucket, local_db_path, 'timet.db')
139
- end
140
-
141
- # Converts database items to a hash indexed by ID
142
- #
143
- # @param items [Array<Hash>] Array of database items
144
- # @return [Hash] Items indexed by ID
145
- def self.items_to_hash(items)
146
- items.to_h { |item| [item['id'], item] }
147
- end
148
-
149
- # Determines if remote item should take precedence
150
- #
151
- # @param remote_item [Hash] Remote database item
152
- # @param remote_time [Integer] Remote item timestamp
153
- # @param local_time [Integer] Local item timestamp
154
- # @return [Boolean] true if remote item should take precedence
155
- def self.remote_wins?(remote_item, remote_time, local_time)
156
- remote_time > local_time && (remote_item['deleted'].to_i == 1 || remote_time > local_time)
157
- end
158
-
159
- # Formats item status message
160
- #
161
- # @param id [Integer] Item ID
162
- # @param item [Hash] Database item
163
- # @param source [String] Source of the item ('Remote' or 'Local')
164
- # @return [String] Formatted status message
165
- def self.format_status_message(id, item, source)
166
- deleted = item['deleted'].to_i == 1 ? ' and deleted' : ''
167
- "#{source} item #{id} is newer#{deleted} - #{source == 'Remote' ? 'updating local' : 'will be uploaded'}"
168
- end
169
-
170
- # Processes an item that exists in both databases
171
- #
172
- # @param id [Integer] Item ID
173
- # @param local_item [Hash] Local database item
174
- # @param remote_item [Hash] Remote database item
175
- # @param local_db [SQLite3::Database] Local database connection
176
- # @return [Symbol] :local_update if local was updated, :remote_update if remote needs update
177
- def self.process_existing_item(*args)
178
- id, local_item, remote_item, local_db = args
179
- local_time = local_item['updated_at'].to_i
180
- remote_time = remote_item['updated_at'].to_i
181
-
182
- if remote_wins?(remote_item, remote_time, local_time)
183
- puts format_status_message(id, remote_item, 'Remote')
184
- update_item_from_hash(local_db, remote_item)
185
- :local_update
186
- elsif local_time > remote_time
187
- puts format_status_message(id, local_item, 'Local')
188
- :remote_update
189
- end
190
- end
191
-
192
- # Processes items from both databases and syncs them
193
- #
194
- # @param local_db [SQLite3::Database] The local database connection
195
- # @param remote_db [SQLite3::Database] The remote database connection
196
- # @return [void]
197
- def self.process_database_items(local_db, remote_db)
198
- remote_items = remote_db.execute('SELECT * FROM items ORDER BY updated_at DESC')
199
- local_items = local_db.execute_sql('SELECT * FROM items ORDER BY updated_at DESC')
200
-
201
- sync_items_by_id(
202
- local_db,
203
- items_to_hash(local_items),
204
- items_to_hash(remote_items)
205
- )
206
- end
207
-
208
- # Syncs items between local and remote databases based on their IDs
209
- #
210
- # @param local_db [SQLite3::Database] The local database connection
211
- # @param local_items_by_id [Hash] Local items indexed by ID
212
- # @param remote_items_by_id [Hash] Remote items indexed by ID
213
- # @return [void]
214
- def self.sync_items_by_id(local_db, local_items_by_id, remote_items_by_id)
215
- all_item_ids = (remote_items_by_id.keys + local_items_by_id.keys).uniq
216
-
217
- all_item_ids.each do |id|
218
- if !remote_items_by_id[id]
219
- puts "Local item #{id} will be uploaded"
220
- elsif !local_items_by_id[id]
221
- puts "Adding remote item #{id} to local"
222
- insert_item_from_hash(local_db, remote_items_by_id[id])
223
- else
224
- process_existing_item(id, local_items_by_id[id], remote_items_by_id[id], local_db)
225
- end
226
- end
227
- end
228
-
229
- # Synchronizes the local and remote databases by comparing and merging their items
230
- #
231
- # @param local_db [SQLite3::Database] The local database connection
232
- # @param remote_db [SQLite3::Database] The remote database connection
233
- # @param remote_storage [S3Supabase] The remote storage client for cloud operations
234
- # @param bucket [String] The S3 bucket name
235
- # @param local_db_path [String] Path to the local database file
236
- # @return [void]
237
- # @note This method orchestrates the entire database synchronization process
238
- def self.sync_databases(*args)
239
- local_db, remote_db, remote_storage, bucket, local_db_path = args
240
- process_database_items(local_db, remote_db)
241
- remote_storage.upload_file(bucket, local_db_path, 'timet.db')
242
- puts 'Database sync completed'
243
- end
244
-
245
- # Gets the values array for database operations
246
- #
247
- # @param item [Hash] Hash containing item data
248
- # @param include_id [Boolean] Whether to include ID at start (insert) or end (update)
249
- # @return [Array] Array of values for database operation
250
- def self.get_item_values(item, include_id_at_start: false)
251
- values = ITEM_FIELDS.map { |field| item[field] }
252
- include_id_at_start ? [item['id'], *values] : [*values, item['id']]
253
- end
254
-
255
- # Updates an existing item in the database with values from a hash
256
- #
257
- # @param db [SQLite3::Database] The database connection
258
- # @param item [Hash] Hash containing item data
259
- # @return [void]
260
- def self.update_item_from_hash(db, item)
261
- fields = "#{ITEM_FIELDS.join(' = ?, ')} = ?"
262
- db.execute_sql(
263
- "UPDATE items SET #{fields} WHERE id = ?",
264
- get_item_values(item)
265
- )
266
- end
267
-
268
- # Inserts a new item into the database from a hash
269
- #
270
- # @param db [SQLite3::Database] The database connection
271
- # @param item [Hash] Hash containing item data
272
- # @return [void]
273
- def self.insert_item_from_hash(db, item)
274
- fields = ['id', *ITEM_FIELDS].join(', ')
275
- placeholders = Array.new(ITEM_FIELDS.length + 1, '?').join(', ')
276
- db.execute_sql(
277
- "INSERT INTO items (#{fields}) VALUES (#{placeholders})",
278
- get_item_values(item, include_id_at_start: true)
279
- )
280
- end
281
79
  end
282
80
  end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timet
4
+ # Module responsible for synchronizing local and remote databases
5
+ module DatabaseSyncer
6
+ # Fields used in item operations
7
+ ITEM_FIELDS = %w[start end tag notes pomodoro updated_at created_at deleted].freeze
8
+
9
+ # Handles the synchronization process when differences are detected between databases
10
+ #
11
+ # @param local_db [SQLite3::Database] The local database connection
12
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
13
+ # @param bucket [String] The S3 bucket name
14
+ # @param local_db_path [String] Path to the local database file
15
+ # @param remote_path [String] Path to the downloaded remote database file
16
+ # @return [void]
17
+ # @note This method attempts to sync the databases and handles any errors that occur during the process
18
+ def handle_database_differences(*args)
19
+ local_db, remote_storage, bucket, local_db_path, remote_path = args
20
+ puts 'Differences detected between local and remote databases'
21
+ begin
22
+ sync_with_remote_database(local_db, remote_path, remote_storage, bucket, local_db_path)
23
+ rescue SQLite3::Exception => e
24
+ handle_sync_error(e, remote_storage, bucket, local_db_path)
25
+ end
26
+ end
27
+
28
+ # Handles errors that occur during database synchronization
29
+ #
30
+ # @param error [SQLite3::Exception] The error that occurred during sync
31
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
32
+ # @param bucket [String] The S3 bucket name
33
+ # @param local_db_path [String] Path to the local database file
34
+ # @return [void]
35
+ # @note When sync fails, this method falls back to uploading the local database
36
+ def handle_sync_error(error, remote_storage, bucket, local_db_path)
37
+ puts "Error opening remote database: #{error.message}"
38
+ puts 'Uploading local database to replace corrupted remote database'
39
+ remote_storage.upload_file(bucket, local_db_path, 'timet.db')
40
+ end
41
+
42
+ # Performs the actual database synchronization by setting up connections and syncing data
43
+ #
44
+ # @param local_db [SQLite3::Database] The local database connection
45
+ # @param remote_path [String] Path to the remote database file
46
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
47
+ # @param bucket [String] The S3 bucket name
48
+ # @param local_db_path [String] Path to the local database file
49
+ # @return [void]
50
+ # @note Configures both databases to return results as hashes for consistent data handling
51
+ def sync_with_remote_database(*args)
52
+ local_db, remote_path, remote_storage, bucket, local_db_path = args
53
+ db_remote = open_remote_database(remote_path)
54
+ db_remote.results_as_hash = true
55
+ local_db.instance_variable_get(:@db).results_as_hash = true
56
+ sync_databases(local_db, db_remote, remote_storage, bucket, local_db_path)
57
+ end
58
+
59
+ # Opens and validates a connection to the remote database
60
+ #
61
+ # @param remote_path [String] Path to the remote database file
62
+ # @return [SQLite3::Database] The initialized database connection
63
+ # @raise [RuntimeError] If the database connection cannot be established
64
+ # @note Validates that the database connection is properly initialized
65
+ def open_remote_database(remote_path)
66
+ db_remote = SQLite3::Database.new(remote_path)
67
+ raise 'Failed to initialize remote database' unless db_remote
68
+
69
+ db_remote
70
+ end
71
+
72
+ # Synchronizes the local and remote databases by comparing and merging their items
73
+ #
74
+ # @param local_db [SQLite3::Database] The local database connection
75
+ # @param remote_db [SQLite3::Database] The remote database connection
76
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
77
+ # @param bucket [String] The S3 bucket name
78
+ # @param local_db_path [String] Path to the local database file
79
+ # @return [void]
80
+ # @note This method orchestrates the entire database synchronization process
81
+ def sync_databases(*args)
82
+ local_db, remote_db, remote_storage, bucket, local_db_path = args
83
+ process_database_items(local_db, remote_db)
84
+ remote_storage.upload_file(bucket, local_db_path, 'timet.db')
85
+ puts 'Database sync completed'
86
+ end
87
+
88
+ # Processes items from both databases and syncs them
89
+ #
90
+ # @param local_db [SQLite3::Database] The local database connection
91
+ # @param remote_db [SQLite3::Database] The remote database connection
92
+ # @return [void]
93
+ def process_database_items(local_db, remote_db)
94
+ remote_items = remote_db.execute('SELECT * FROM items ORDER BY updated_at DESC')
95
+ local_items = local_db.execute_sql('SELECT * FROM items ORDER BY updated_at DESC')
96
+
97
+ sync_items_by_id(
98
+ local_db,
99
+ items_to_hash(local_items),
100
+ items_to_hash(remote_items)
101
+ )
102
+ end
103
+
104
+ # Syncs items between local and remote databases based on their IDs
105
+ #
106
+ # @param local_db [SQLite3::Database] The local database connection
107
+ # @param local_items_by_id [Hash] Local items indexed by ID
108
+ # @param remote_items_by_id [Hash] Remote items indexed by ID
109
+ # @return [void]
110
+ def sync_items_by_id(local_db, local_items_by_id, remote_items_by_id)
111
+ all_item_ids = (remote_items_by_id.keys + local_items_by_id.keys).uniq
112
+
113
+ all_item_ids.each do |id|
114
+ if !remote_items_by_id[id]
115
+ puts "Local item #{id} will be uploaded"
116
+ elsif !local_items_by_id[id]
117
+ puts "Adding remote item #{id} to local"
118
+ insert_item_from_hash(local_db, remote_items_by_id[id])
119
+ else
120
+ process_existing_item(id, local_items_by_id[id], remote_items_by_id[id], local_db)
121
+ end
122
+ end
123
+ end
124
+
125
+ # Inserts a new item into the database from a hash
126
+ #
127
+ # @param db [SQLite3::Database] The database connection
128
+ # @param item [Hash] Hash containing item data
129
+ # @return [void]
130
+ def insert_item_from_hash(db, item)
131
+ fields = ['id', *ITEM_FIELDS].join(', ')
132
+ placeholders = Array.new(ITEM_FIELDS.length + 1, '?').join(', ')
133
+ db.execute_sql(
134
+ "INSERT INTO items (#{fields}) VALUES (#{placeholders})",
135
+ get_item_values(item, include_id_at_start: true)
136
+ )
137
+ end
138
+
139
+ # Processes an item that exists in both databases
140
+ #
141
+ # @param id [Integer] Item ID
142
+ # @param local_item [Hash] Local database item
143
+ # @param remote_item [Hash] Remote database item
144
+ # @param local_db [SQLite3::Database] Local database connection
145
+ # @return [Symbol] :local_update if local was updated, :remote_update if remote needs update
146
+ def process_existing_item(*args)
147
+ id, local_item, remote_item, local_db = args
148
+ local_time = local_item['updated_at'].to_i
149
+ remote_time = remote_item['updated_at'].to_i
150
+
151
+ if remote_wins?(remote_item, remote_time, local_time)
152
+ puts format_status_message(id, remote_item, 'Remote')
153
+ update_item_from_hash(local_db, remote_item)
154
+ :local_update
155
+ elsif local_time > remote_time
156
+ puts format_status_message(id, local_item, 'Local')
157
+ :remote_update
158
+ end
159
+ end
160
+
161
+ # Converts database items to a hash indexed by ID
162
+ #
163
+ # @param items [Array<Hash>] Array of database items
164
+ # @return [Hash] Items indexed by ID
165
+ def items_to_hash(items)
166
+ items.to_h { |item| [item['id'], item] }
167
+ end
168
+
169
+ # Determines if remote item should take precedence
170
+ #
171
+ # @param remote_item [Hash] Remote database item
172
+ # @param remote_time [Integer] Remote item timestamp
173
+ # @param local_time [Integer] Local item timestamp
174
+ # @return [Boolean] true if remote item should take precedence
175
+ def remote_wins?(remote_item, remote_time, local_time)
176
+ remote_time > local_time && (remote_item['deleted'].to_i == 1 || remote_time > local_time)
177
+ end
178
+
179
+ # Formats item status message
180
+ #
181
+ # @param id [Integer] Item ID
182
+ # @param item [Hash] Database item
183
+ # @param source [String] Source of the item ('Remote' or 'Local')
184
+ # @return [String] Formatted status message
185
+ def format_status_message(id, item, source)
186
+ deleted = item['deleted'].to_i == 1 ? ' and deleted' : ''
187
+ "#{source} item #{id} is newer#{deleted} - #{source == 'Remote' ? 'updating local' : 'will be uploaded'}"
188
+ end
189
+
190
+ # Updates an existing item in the database with values from a hash
191
+ #
192
+ # @param db [SQLite3::Database] The database connection
193
+ # @param item [Hash] Hash containing item data
194
+ # @return [void]
195
+ def update_item_from_hash(db, item)
196
+ fields = "#{ITEM_FIELDS.join(' = ?, ')} = ?"
197
+ db.execute_sql(
198
+ "UPDATE items SET #{fields} WHERE id = ?",
199
+ get_item_values(item)
200
+ )
201
+ end
202
+
203
+ # Gets the values array for database operations
204
+ #
205
+ # @param item [Hash] Hash containing item data
206
+ # @param include_id [Boolean] Whether to include ID at start (insert) or end (update)
207
+ # @return [Array] Array of values for database operation
208
+ def get_item_values(item, include_id_at_start: false)
209
+ @database_fields ||= ITEM_FIELDS
210
+ values = @database_fields.map { |field| item[field] }
211
+ include_id_at_start ? [item['id'], *values] : values
212
+ end
213
+ end
214
+ end
@@ -179,6 +179,7 @@ module Timet
179
179
  @logger.info "Object '#{object_key}' deleted successfully."
180
180
  rescue Aws::S3::Errors::ServiceError => e
181
181
  @logger.error "Error deleting object: #{e.message}"
182
+ raise e
182
183
  end
183
184
 
184
185
  # Deletes a bucket and all its contents.
@@ -197,6 +198,7 @@ module Timet
197
198
  @logger.info "Bucket '#{bucket_name}' deleted successfully."
198
199
  rescue Aws::S3::Errors::ServiceError => e
199
200
  @logger.error "Error deleting bucket: #{e.message}"
201
+ raise e
200
202
  end
201
203
 
202
204
  private
@@ -207,14 +209,19 @@ module Timet
207
209
  # @return [void]
208
210
  def validate_env_vars
209
211
  missing_vars = []
210
- missing_vars << 'S3_ENDPOINT' if S3_ENDPOINT.empty?
211
- missing_vars << 'S3_ACCESS_KEY' if S3_ACCESS_KEY.empty?
212
- missing_vars << 'S3_SECRET_KEY' if S3_SECRET_KEY.empty?
212
+ missing_vars.concat(check_env_var('S3_ENDPOINT', S3_ENDPOINT))
213
+ missing_vars.concat(check_env_var('S3_ACCESS_KEY', S3_ACCESS_KEY))
214
+ missing_vars.concat(check_env_var('S3_SECRET_KEY', S3_SECRET_KEY))
213
215
 
214
216
  return if missing_vars.empty?
215
217
 
216
- error_message = "Missing required environment variables (.env): #{missing_vars.join(', ')}"
217
- raise CustomError, error_message
218
+ raise CustomError, "Missing required environment variables (.env): #{missing_vars.join(', ')}"
219
+ end
220
+
221
+ def check_env_var(name, value)
222
+ return [] if value && !value.empty?
223
+
224
+ [name]
218
225
  end
219
226
 
220
227
  # Custom error class that suppresses the backtrace for cleaner error messages.
data/lib/timet/table.rb CHANGED
@@ -1,9 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # require_relative 'color_codes'
4
+ require 'timet/time_report_helper'
3
5
  module Timet
4
- # This module is responsible for formatting the output of the `timet` application.
6
+ # This class is responsible for formatting the output of the `timet` application.
5
7
  # It provides methods for formatting the table header, separators, and rows.
6
- module Table
8
+ class Table
9
+ include TimeReportHelper
10
+
11
+ attr_reader :filter, :items
12
+
13
+ def initialize(filter, items, db)
14
+ @filter = filter
15
+ @items = items
16
+ @db = db
17
+ end
18
+
7
19
  # Generates and displays a table summarizing time entries, including headers, time blocks, and total durations.
8
20
  #
9
21
  # @example
@@ -64,22 +76,26 @@ module Timet
64
76
 
65
77
  # Processes time entries and generates a time block structure.
66
78
  #
67
- # @return [Hash] A nested hash representing the time block structure.
79
+ # This method iterates over each item in the `items` array, displays the time entry (if enabled),
80
+ # and processes the time block item to build a nested hash representing the time block structure.
81
+ #
82
+ # @param display [Boolean] Whether to display the time entry during processing. Defaults to `true`.
83
+ # @return [Hash] A nested hash representing the time block structure, where keys are dates and values
84
+ # are processed time block items.
68
85
  #
69
86
  # @note
70
- # - The method iterates over each item in the `items` array.
71
- # - For each item, it calls `display_time_entry` to display the time entry.
72
- # - It then processes the time block item using `process_time_block_item`.
87
+ # - The method uses `display_time_entry` to display the time entry if `display` is `true`.
88
+ # - It processes each time block item using `process_time_block_item`.
73
89
  # - The `TimeHelper.extract_date` method is used to extract the date from the items.
74
90
  #
75
91
  # @see #display_time_entry
76
92
  # @see #process_time_block_item
77
93
  # @see TimeHelper#extract_date
78
- def process_time_entries
94
+ def process_time_entries(display: true)
79
95
  time_block = Hash.new { |hash, key| hash[key] = {} }
80
96
 
81
- items.each_with_index do |item, idx|
82
- display_time_entry(item, TimeHelper.extract_date(items, idx))
97
+ @items.each_with_index do |item, idx|
98
+ display_time_entry(item, TimeHelper.extract_date(@items, idx)) if display
83
99
  time_block = process_time_block_item(item, time_block)
84
100
  end
85
101
 
@@ -12,8 +12,8 @@ module Timet
12
12
  # "2023-10-02" => { "10" => [4500, "work"] }
13
13
  # }
14
14
  # colors = { "work" => 31, "break" => 32 }
15
- # chart = TimeBlockChart.new(time_block)
16
- # chart.print_time_block_chart(time_block, colors)
15
+ # chart = TimeBlockChart.new(table)
16
+ # chart.print_time_block_chart(table, colors)
17
17
  #
18
18
  # @attr_reader [Integer] start_hour The starting hour of the time block
19
19
  # @attr_reader [Integer] end_hour The ending hour of the time block
@@ -34,22 +34,49 @@ module Timet
34
34
  # Separator character for the chart
35
35
  SEPARATOR_CHAR = '░'
36
36
 
37
- # Initializes a new TimeBlockChart
37
+ # Initializes a new TimeBlockChart instance.
38
38
  #
39
- # @param [Hash] time_block The time block data
40
- def initialize(time_block)
41
- @start_hour = time_block.values.map(&:keys).flatten.uniq.min.to_i
42
- @end_hour = time_block.values.map(&:keys).flatten.uniq.max.to_i
39
+ # This method sets up the time block chart by processing the time entries from the provided table
40
+ # and determining the start and end hours for the chart based on the time block data.
41
+ #
42
+ # @param table [Table] The table instance containing the time entries to be processed.
43
+ # @return [void] This method does not return a value; it initializes the instance variables.
44
+ #
45
+ # @note
46
+ # - The `@time_block` instance variable is populated by processing the time entries from the table.
47
+ # - The `@start_hour` and `@end_hour` instance variables are calculated based on the earliest and latest
48
+ # hours present in the time block data.
49
+ #
50
+ # @see Table#process_time_entries
51
+ def initialize(table)
52
+ @time_block = table.process_time_entries(display: false)
53
+ @start_hour = @time_block.values.map(&:keys).flatten.uniq.min.to_i
54
+ @end_hour = @time_block.values.map(&:keys).flatten.uniq.max.to_i
43
55
  end
44
56
 
45
- # Prints the time block chart
57
+ # Prints the time block chart.
46
58
  #
47
- # @param [Hash] time_block The time block data
48
- # @param [Hash] colors The color mapping for different tags
49
- # @return [void]
50
- def print_time_block_chart(time_block, colors)
59
+ # This method formats and prints the time block chart, including the header and the time blocks
60
+ # for each entry. The chart is color-coded based on the provided color mapping for different tags.
61
+ #
62
+ # @param table [Hash] The time block data to be displayed in the chart.
63
+ # @param colors [Hash] A mapping of tags to colors, used to color-code the time blocks.
64
+ # @return [void] This method does not return a value; it performs side effects such as printing the chart.
65
+ #
66
+ # @example Print a time block chart
67
+ # chart = TimeBlockChart.new(table)
68
+ # chart.print_time_block_chart(table, colors)
69
+ #
70
+ # @note
71
+ # - The method first prints the header of the chart, which includes the time range.
72
+ # - It then prints the time blocks, using the provided color mapping to visually distinguish
73
+ # between different tags.
74
+ #
75
+ # @see #print_header
76
+ # @see #print_blocks
77
+ def print_time_block_chart(table, colors)
51
78
  print_header
52
- print_blocks(time_block, colors)
79
+ print_blocks(table, colors)
53
80
  end
54
81
 
55
82
  private
@@ -65,22 +92,44 @@ module Timet
65
92
  puts '┌╴W ╴╴╴╴╴╴⏰╴╴╴╴╴╴┼'.gray + "#{'╴' * (@end_hour - @start_hour + 1) * 4}╴╴╴┼".gray
66
93
  end
67
94
 
68
- # Prints the time blocks
95
+ # Prints the time blocks for each date in the time block data.
69
96
  #
70
- # @param [Hash] time_block The time block data
71
- # @param [Hash] colors The color mapping for different tags
72
- # @return [void]
73
- def print_blocks(time_block, colors)
74
- return unless time_block
97
+ # This method iterates over the time block data, formats and prints the date information,
98
+ # prints the time blocks for each date using the provided color mapping, and calculates
99
+ # and prints the total hours for each day. It also prints a footer at the end.
100
+ #
101
+ # @param table [Hash] The time block data containing the time entries for each date.
102
+ # @param colors [Hash] A mapping of tags to colors, used to color-code the time blocks.
103
+ # @return [void] This method does not return a value; it performs side effects such as printing
104
+ # the time blocks and related information.
105
+ #
106
+ # @example Print time blocks
107
+ # chart = TimeBlockChart.new(table)
108
+ # chart.print_blocks(table, colors)
109
+ #
110
+ # @note
111
+ # - The method skips processing if the `table` parameter is `nil`.
112
+ # - For each date in the time block data, it formats and prints the date and day of the week.
113
+ # - It prints the time blocks using the provided color mapping to visually distinguish
114
+ # between different tags.
115
+ # - It calculates and prints the total hours for each day.
116
+ # - A footer is printed at the end to provide a visual separation.
117
+ #
118
+ # @see #format_and_print_date_info
119
+ # @see #print_time_blocks
120
+ # @see #calculate_and_print_hours
121
+ # @see #print_footer
122
+ def print_blocks(table, colors)
123
+ return unless table
75
124
 
76
125
  weeks = []
77
- time_block.each_key do |date_string|
126
+ @time_block.each_key do |date_string|
78
127
  date = Date.parse(date_string)
79
128
  day = date.strftime('%a')[0..2]
80
129
 
81
130
  format_and_print_date_info(date_string, day, weeks)
82
131
 
83
- time_block_initial = time_block[date_string]
132
+ time_block_initial = @time_block[date_string]
84
133
  print_time_blocks(time_block_initial, colors)
85
134
 
86
135
  calculate_and_print_hours(time_block_initial)
@@ -14,7 +14,6 @@ module Timet
14
14
  # a formatted table with the relevant information.
15
15
  class TimeReport
16
16
  include TimeReportHelper
17
- include Table
18
17
  include TagDistribution
19
18
 
20
19
  # Provides access to the database instance.
@@ -32,37 +31,55 @@ module Timet
32
31
  # Initializes a new instance of the TimeReport class.
33
32
  #
34
33
  # @param db [Database] The database instance to use for fetching data.
35
- # @param options [Hash] A hash containing optional parameters.
36
- # @option options [String, nil] :filter The filter to apply when fetching items. Possible values include 'today',
37
- # 'yesterday', 'week', 'month', or a date range in the format 'YYYY-MM-DD..YYYY-MM-DD'.
38
- # @option options [String, nil] :tag The tag to filter the items by.
39
- # @option options [String, nil] :csv The filename to use when exporting the report to CSV.
40
- # @option options [String, nil] :ics The filename to use when exporting the report to iCalendar.
41
- #
42
- # @return [void] This method does not return a value; it performs side effects such as initializing the
43
- # instance variables.
34
+ # @param options [Hash] A hash containing optional parameters for configuring the report.
35
+ # @option options [String, nil] :filter The filter to apply when fetching items. Possible values include:
36
+ # - 'today': Filters items for the current day.
37
+ # - 'yesterday': Filters items for the previous day.
38
+ # - 'week': Filters items for the current week.
39
+ # - 'month': Filters items for the current month.
40
+ # - A date range in the format 'YYYY-MM-DD..YYYY-MM-DD': Filters items within the specified date range.
41
+ # @option options [String, nil] :tag The tag to filter the items by. Only items with this tag will be included.
42
+ # @option options [String, nil] :csv The filename to use when exporting the report to CSV. If provided, the report
43
+ # will be exported to the specified file.
44
+ # @option options [String, nil] :ics The filename to use when exporting the report to iCalendar format. If provided,
45
+ # the report will be exported to the specified file.
46
+ #
47
+ # @return [void] This method does not return a value; it initializes the instance variables and prepares the report.
44
48
  #
45
49
  # @example Initialize a new TimeReport instance with a filter and tag
46
50
  # TimeReport.new(db, filter: 'today', tag: 'work', csv: 'report.csv', ics: 'icalendar.ics')
51
+ #
52
+ # @example Initialize a new TimeReport instance with a date range filter
53
+ # TimeReport.new(db, filter: '2023-10-01..2023-10-31', tag: 'project')
54
+ #
55
+ # @note
56
+ # - If no filter is provided, all items from the database will be fetched.
57
+ # - The `@table` instance variable is initialized with the filtered items and filter configuration.
47
58
  def initialize(db, options = {})
48
59
  @db = db
49
60
  @csv_filename = options[:csv]
50
61
  @ics_filename = options[:ics]
51
62
  @filter = formatted_filter(options[:filter])
52
63
  @items = options[:filter] ? filter_items(@filter, options[:tag]) : @db.all_items
64
+ @table = Table.new(@filter, @items, @db)
53
65
  end
54
66
 
55
67
  # Displays the report of tracked time entries.
56
68
  #
69
+ # This method formats and prints the report, including the table header, rows, total duration,
70
+ # a time block chart, and tag distribution. If no tracked time entries are found for the specified filter,
71
+ # it displays a message indicating no data is available.
72
+ #
57
73
  # @return [void] This method does not return a value; it performs side effects such as printing the report.
58
74
  #
59
75
  # @example Display the report
60
76
  # time_report.display
61
77
  #
62
- # @note The method formats and prints the table header, rows, and total duration.
63
- #
64
- # @param items [Array<Hash>] The list of time entries to be displayed.
65
- # @param options [Hash] Additional options for customizing the display (e.g., color scheme).
78
+ # @note
79
+ # - The method checks if there are any tracked time entries. If not, it prints a message and exits.
80
+ # - It uses the `@table` instance to format and display the table.
81
+ # - A time block chart is generated and printed using the `TimeBlockChart` class.
82
+ # - The tag distribution is calculated and displayed based on the unique colors assigned to tags.
66
83
  #
67
84
  # @see #table
68
85
  # @see #print_time_block_chart
@@ -70,30 +87,38 @@ module Timet
70
87
  def display
71
88
  return puts 'No tracked time found for the specified filter.' if @items.empty?
72
89
 
73
- time_block = table
74
-
90
+ @table.table
75
91
  colors = @items.map { |x| x[3] }.uniq.each_with_index.to_h
76
- chart = TimeBlockChart.new(time_block)
77
- chart.print_time_block_chart(time_block, colors)
78
-
92
+ chart = TimeBlockChart.new(@table)
93
+ chart.print_time_block_chart(@table, colors)
79
94
  tag_distribution(colors)
80
95
  end
81
96
 
82
97
  # Displays a single row of the report.
83
98
  #
84
- # @param item [Array] The item to display.
99
+ # This method formats and prints a single row of the report, including the table header, the specified row,
100
+ # a separator, and the total duration. It is used to display individual time entries in a structured format.
101
+ #
102
+ # @param item [Array] The item (time entry) to display. The item is expected to contain the necessary data
103
+ # for the row, such as the time, description, and duration.
85
104
  #
86
105
  # @return [void] This method does not return a value; it performs side effects such as printing the row.
87
106
  #
88
107
  # @example Display a single row
89
108
  # time_report.show_row(item)
90
109
  #
91
- # @note The method formats and prints the table header, row, and total duration.
110
+ # @note
111
+ # - The method uses the `@table` instance to format and display the table header and row.
112
+ # - A separator is printed after the row to visually distinguish it from other rows.
113
+ # - The total duration is displayed at the end of the row.
114
+ #
115
+ # @see #table
116
+ # @see #display_time_entry
92
117
  def show_row(item)
93
- header
94
- display_time_entry(item)
95
- puts separator
96
- total
118
+ @table.header
119
+ @table.display_time_entry(item)
120
+ puts @table.separator
121
+ @table.total
97
122
  end
98
123
 
99
124
  private
@@ -83,18 +83,18 @@ module Timet
83
83
  #
84
84
  # @param item [Array] The tracking item to be updated.
85
85
  # @param field [String] The time field to be updated.
86
- # @param formatted_value [String] The formatted date value.
86
+ # @param new_time [String] The new time value.
87
87
  #
88
88
  # @return [Time] The updated time value.
89
89
  #
90
90
  # @example Update the 'start' field of a tracking item with a formatted date value
91
- # update_time_field(item, 'start', '2023-10-01 12:00:00')
92
- def update_time_field(item, field, formatted_value)
91
+ # update_time_field(item, 'start', '11:10:00')
92
+ def update_time_field(item, field, new_time)
93
93
  field_index = Timet::Application::FIELD_INDEX[field]
94
94
  timestamp = item[field_index]
95
- current_time = Time.at(timestamp || TimeHelper.current_timestamp).to_s.split
96
- current_time[1] = formatted_value
97
- DateTime.strptime(current_time.join(' '), '%Y-%m-%d %H:%M:%S %z').to_time
95
+ edit_time = Time.at(timestamp || item[1]).to_s.split
96
+ edit_time[1] = new_time
97
+ DateTime.strptime(edit_time.join(' '), '%Y-%m-%d %H:%M:%S %z').to_time
98
98
  end
99
99
 
100
100
  # Validates if a new time value is valid for a specific time field (start or end).
data/lib/timet/version.rb CHANGED
@@ -6,6 +6,6 @@ module Timet
6
6
  # @return [String] The version number in the format 'major.minor.patch'.
7
7
  #
8
8
  # @example Get the version of the Timet application
9
- # Timet::VERSION # => '1.5.3'
10
- VERSION = '1.5.3'
9
+ # Timet::VERSION # => '1.5.5'
10
+ VERSION = '1.5.5'
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timet
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.3
4
+ version: 1.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frank Vielma
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-02 00:00:00.000000000 Z
11
+ date: 2025-02-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -106,14 +106,14 @@ dependencies:
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '1.171'
109
+ version: '1.178'
110
110
  type: :runtime
111
111
  prerelease: false
112
112
  version_requirements: !ruby/object:Gem::Requirement
113
113
  requirements:
114
114
  - - "~>"
115
115
  - !ruby/object:Gem::Version
116
- version: '1.171'
116
+ version: '1.178'
117
117
  description: Timet is a command-line time tracker that keeps track of your activities.
118
118
  email:
119
119
  - frankvielma@gmail.com
@@ -139,6 +139,7 @@ files:
139
139
  - lib/timet/color_codes.rb
140
140
  - lib/timet/database.rb
141
141
  - lib/timet/database_sync_helper.rb
142
+ - lib/timet/database_syncer.rb
142
143
  - lib/timet/s3_supabase.rb
143
144
  - lib/timet/table.rb
144
145
  - lib/timet/tag_distribution.rb