toilet_tracker 0.1.0 β†’ 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2cd32c62afad4d76b2c46dc4747fd6140c458dd7d5d7ee63d1d4c562761087f2
4
- data.tar.gz: dc38bc38cd7f5610a6fde62c861e9e38d0c46908f2703098f775ffad8f32ae71
3
+ metadata.gz: 4e179c9943225797758be0ca803a1dcde9ee9f7b98ee7466d74cb208a49040b8
4
+ data.tar.gz: ea5b5ab8fb0e4da1ca4a8e7e48575bc72a9a967769c375e52e11dcfab3dd4801
5
5
  SHA512:
6
- metadata.gz: 7f347d353402c1b4c3189ac6fabc9098b03c32caf6c04d729e17320e07693778ff81c698540f47923d24f585f189b02bdb913cc940f89dae98cb3d475c312da6
7
- data.tar.gz: 7ff87b8075a25a879a96bcdb6c903e03d09419c45e1285b669a48f9184f6995852be152e1cdf98c46c08933e37515898d21818b29fc24dc5d3c3f4dc066ef1e1
6
+ metadata.gz: 33081c85110a522542ae3c50209cbe5a1d302bb34bb513bc4c6118daaabf0093673e37695a7200818ed77d5e37bd725e22074a7898cd82f63df9518c105beaf9
7
+ data.tar.gz: 0ebf346d3c2afde44224885c4879bad74e1d29203c7c2f567fc573fbc989e48eee71f613257707df3e4b4359a861356111cd1e2395f9035d573259de6d0579c8
data/.rubocop.yml CHANGED
@@ -1,37 +1,2 @@
1
- AllCops:
2
- NewCops: enable
3
- SuggestExtensions: false
4
-
5
- # Disable metrics cops for now
6
- Metrics/BlockLength:
7
- Enabled: false
8
-
9
- Metrics/MethodLength:
10
- Enabled: false
11
-
12
- Metrics/AbcSize:
13
- Enabled: false
14
-
15
- Metrics/CyclomaticComplexity:
16
- Enabled: false
17
-
18
- Metrics/ClassLength:
19
- Enabled: false
20
-
21
- Metrics/PerceivedComplexity:
22
- Enabled: false
23
-
24
- # Disable documentation requirement
25
- Style/Documentation:
26
- Enabled: false
27
-
28
- # Increase line length limit or disable
29
- Layout/LineLength:
30
- Max: 200
31
-
32
- # Disable remaining Lint cops that need manual fixes
33
- Lint/DuplicateBranch:
34
- Enabled: false
35
-
36
- Lint/UnreachableLoop:
37
- Enabled: false
1
+ inherit_gem:
2
+ rubocop-rails-omakase: rubocop.yml
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1] - 2025-12-18
4
+
5
+ ### Changed
6
+ - Update activesupport to 8.1.1 (from 8.0.2.1)
7
+ - Update rubyzip to 3.2.2 (from 3.0.1)
8
+ - Update rake to 13.3.1 (from 13.3.0)
9
+ - Update rspec to 3.13.2 (from 3.13.1)
10
+ - Update overcommit to 0.68.0 (from 0.67.1)
11
+ - Relax version constraints for activesupport and rubyzip
12
+
13
+ ## [0.2.0] - 2025-12-18
14
+
15
+ ### Added
16
+ - Metadata emoji support for both πŸ’© and 🚽 commands
17
+ - Format: `[command emoji] [metadata emojis...] [shift/timezone] [time]`
18
+ - Metadata stored as array of individual emoji strings
19
+ - New `metadata` field on `PoopEventResult` and `ToiletShiftResult`
20
+ - `BaseParser#parse_metadata` and `#metadata_emoji?` helper methods
21
+ - Comprehensive test suite for metadata parsing (28 new tests)
22
+
23
+ ### Changed
24
+ - All regex patterns updated to capture optional metadata group
25
+ - Result objects now include `metadata: []` by default (backward compatible)
26
+
27
+ ## [0.1.1] - 2025-12-18
28
+
29
+ ### Added
30
+ - Fractional timezone shift support (`+5:30` β†’ 5.5 hours)
31
+ - Time-only patterns for events and settings (`πŸ’© 14:30`, `🚽 +5:30 14:30`)
32
+ - Timezone-to-shift conversion (returns calculated shift instead of timezone name)
33
+ - New regex patterns with example comments for clarity
34
+
35
+ ### Changed
36
+ - `Configuration#valid_shift?` now accepts Float values
37
+ - `TimezoneService#calculate_shift` returns Float for fractional hours
38
+ - Replaced StandardRB with RuboCop Omakase for linting
39
+
3
40
  ## [0.1.0] - 2025-06-26
4
41
 
5
42
  - Initial release
data/PARSING_SPEC.md CHANGED
@@ -20,6 +20,24 @@ All WhatsApp messages follow this format:
20
20
  - **Functional Shifts**: Applied to event timestamps (patterns 1, 2, 5)
21
21
  - **Decorative Shifts**: For statistics only, do not affect timestamps (patterns 7, 9)
22
22
 
23
+ ### Metadata Emojis
24
+ Both πŸ’© and 🚽 commands support optional metadata emojis placed between the command emoji and the time/shift specifications. Metadata emojis are stored as an array and can be used for additional categorization or tagging of events.
25
+
26
+ **Format**: `[command emoji] [metadata emojis...] [shift/timezone] [time]`
27
+
28
+ **Examples**:
29
+ - `πŸ’© 🩸` β†’ Event with metadata `["🩸"]`
30
+ - `πŸ’© πŸ©ΈπŸ’§πŸ”₯` β†’ Event with metadata `["🩸", "πŸ’§", "πŸ”₯"]`
31
+ - `πŸ’© 🩸 +2` β†’ Event with metadata `["🩸"]` and shift `+2`
32
+ - `🚽 ✈️🌍 +5:30` β†’ Setting with metadata `["✈️", "🌍"]` and shift `+5:30`
33
+
34
+ **Behavior**:
35
+ - Metadata is any emoji placed after the command emoji but before time specs
36
+ - Emojis are extracted as individual grapheme clusters (supports complex emojis)
37
+ - Command emojis (πŸ’©, 🚽) are excluded from metadata
38
+ - Stored as an array of strings in result objects
39
+ - Does not affect parsing of shifts, timezones, or times
40
+
23
41
  ## Supported Parsing Patterns
24
42
 
25
43
  ### WhatsApp Message Format
@@ -262,8 +280,9 @@ When timezone shifts are set retroactively:
262
280
  type: :shift_set | :shift_set_at_time | :timezone_set,
263
281
  status: :ok | :alert | :error,
264
282
  message: Core::Message,
265
- shift: Integer,
283
+ shift: Integer | Float,
266
284
  effective_time: Time,
285
+ metadata: Array<String>,
267
286
  error_details: String | nil
268
287
  }
269
288
  ```
@@ -275,9 +294,10 @@ When timezone shifts are set retroactively:
275
294
  :event_in_timezone | :event_in_timezone_at_time,
276
295
  status: :ok | :alert | :error,
277
296
  message: Core::Message,
278
- shift: Integer | nil,
297
+ shift: Integer | Float | nil,
279
298
  poop_time: Time,
280
299
  timezone: String | nil,
300
+ metadata: Array<String>,
281
301
  error_details: String | nil
282
302
  }
283
303
  ```
@@ -342,6 +362,16 @@ When timezone shifts are set retroactively:
342
362
  [02/01/25, 15:00:00] Bob: πŸ’© PST 2025-01-02 12:00:00
343
363
  ```
344
364
 
365
+ ### Valid Examples with Metadata
366
+ ```
367
+ [01/01/25, 08:30:00] Alice: πŸ’© 🩸 # metadata: ["🩸"]
368
+ [01/01/25, 10:00:00] Alice: πŸ’© πŸ©ΈπŸ’§ +2 # metadata: ["🩸", "πŸ’§"], shift: 2
369
+ [01/01/25, 12:00:00] Alice: πŸ’© 🧱 14:30 # metadata: ["🧱"], time: 14:30
370
+ [01/01/25, 14:00:00] Alice: πŸ’© πŸ”₯ +2 2025-01-01 13:00 # metadata + shift + time
371
+ [02/01/25, 09:00:00] Bob: 🚽 ✈️ +5:30 # metadata: ["✈️"], shift: 5.5
372
+ [02/01/25, 10:00:00] Bob: 🚽 🏠✈️ Europe/Rome # metadata: ["🏠", "✈️"], timezone
373
+ ```
374
+
345
375
  ### Invalid Examples
346
376
  ```
347
377
  [01/01/25, 10:00:00] Alice: 🚽 +25 # Exceeds max_shift_hours
data/README.md CHANGED
@@ -16,7 +16,7 @@ A modern Ruby gem for parsing WhatsApp messages containing toilet tracking data
16
16
  ## Requirements
17
17
 
18
18
  - **Ruby 3.4+** - Uses modern Ruby features for optimal performance
19
- - **StandardRB** - Zero-configuration linting and formatting
19
+ - **RuboCop Omakase** - Opinionated Ruby styling from the Rails team
20
20
 
21
21
  ## Installation
22
22
 
@@ -186,11 +186,11 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
186
186
  # Run tests
187
187
  bundle exec rspec
188
188
 
189
- # Check code style (StandardRB)
190
- bundle exec standardrb
189
+ # Check code style (RuboCop)
190
+ bundle exec rubocop
191
191
 
192
192
  # Auto-fix style issues
193
- bundle exec standardrb --fix
193
+ bundle exec rubocop -a
194
194
 
195
195
  # Install gem locally
196
196
  bundle exec rake install
@@ -198,7 +198,7 @@ bundle exec rake install
198
198
 
199
199
  ### Code Quality
200
200
 
201
- This project uses **StandardRB** for zero-configuration Ruby linting and formatting. StandardRB eliminates style debates by providing an opinionated, unconfigurable set of rules.
201
+ This project uses **RuboCop Omakase** for opinionated Ruby linting and formatting. Omakase provides the Rails team's preferred styling rules.
202
202
 
203
203
  Key principles:
204
204
  - **Ruby 3.4+ features** - Modern syntax and performance optimizations
data/exe/toilet_tracker CHANGED
@@ -95,7 +95,7 @@ module ToiletTracker
95
95
 
96
96
  when :manual_entry
97
97
  message = prompt.ask('Enter WhatsApp message:')
98
- results = ToiletTracker.parse_lines([message])
98
+ results = ToiletTracker.parse_lines([ message ])
99
99
  handle_interactive_results(results, prompt)
100
100
 
101
101
  when :exit
@@ -103,7 +103,7 @@ module ToiletTracker
103
103
  break
104
104
  end
105
105
 
106
- prompt.keypress('Press any key to continue...', keys: [:return])
106
+ prompt.keypress('Press any key to continue...', keys: [ :return ])
107
107
  end
108
108
  end
109
109
 
@@ -177,7 +177,7 @@ module ToiletTracker
177
177
  when 'csv'
178
178
  require 'csv'
179
179
  CSV.open(filename, 'w') do |csv|
180
- csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
180
+ csv << [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
181
181
  filter_results(results).each do |result|
182
182
  next unless result
183
183
 
@@ -199,7 +199,7 @@ module ToiletTracker
199
199
  if filtered_results.empty?
200
200
  file.puts 'No results found.'
201
201
  else
202
- headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
202
+ headers = [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
203
203
  rows = filtered_results.filter_map do |result|
204
204
  next unless result
205
205
 
@@ -216,7 +216,7 @@ module ToiletTracker
216
216
 
217
217
  # Calculate column widths
218
218
  widths = headers.map.with_index do |header, i|
219
- [header.length, *rows.map { |row| row[i].to_s.length }].max
219
+ [ header.length, *rows.map { |row| row[i].to_s.length } ].max
220
220
  end
221
221
 
222
222
  # Write header
@@ -296,7 +296,7 @@ module ToiletTracker
296
296
 
297
297
  def output_csv(results)
298
298
  CSV($stdout) do |csv|
299
- csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
299
+ csv << [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
300
300
 
301
301
  results.each do |result|
302
302
  next unless result
@@ -318,7 +318,7 @@ module ToiletTracker
318
318
  def output_table(results)
319
319
  return puts 'No results found.' if results.empty?
320
320
 
321
- headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
321
+ headers = [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
322
322
  rows = results.filter_map do |result|
323
323
  next unless result
324
324
 
@@ -335,7 +335,7 @@ module ToiletTracker
335
335
 
336
336
  # Calculate column widths
337
337
  widths = headers.map.with_index do |header, i|
338
- [header.length, *rows.map { |row| row[i].to_s.length }].max
338
+ [ header.length, *rows.map { |row| row[i].to_s.length } ].max
339
339
  end
340
340
 
341
341
  # Print header
@@ -1,30 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/time'
3
+ require "active_support/time"
4
4
 
5
5
  module ToiletTracker
6
6
  class Configuration
7
7
  attr_accessor :default_timezone, :max_shift_hours, :message_patterns, :date_formats
8
8
 
9
9
  def initialize
10
- @default_timezone = 'UTC'
10
+ @default_timezone = "UTC"
11
11
  @max_shift_hours = 12
12
+ # Capture group 1 is always metadata emojis, subsequent groups are pattern-specific
12
13
  @message_patterns = {
13
- shift_set: /🚽\s*([+-]?\d+)$/,
14
- shift_set_at_time: /🚽\s*([+-]?\d+)\s+(.+)$/,
15
- timezone_set: %r{🚽\s*([A-Za-z_/][A-Za-z0-9_/+-]*)$},
16
- event_now: /πŸ’©$/,
17
- event_now_shifted: /πŸ’©\s*([+-]?\d+)$/,
18
- event_at_time: %r{πŸ’©\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
19
- event_at_time_shifted: %r{πŸ’©\s*([+-]?\d+)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)},
20
- event_in_timezone: %r{πŸ’©\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s*$},
21
- event_in_timezone_at_time: %r{πŸ’©\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$}
14
+ # πŸ’© 🩸 14:30
15
+ event_at_time_only: /πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*(\d{1,2}:\d{2})$/,
16
+ # πŸ’© 🩸 +5 14:30, πŸ’© 🩸 +5:30 14:30
17
+ event_at_time_only_shifted: /πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{1,2}:\d{2})$/,
18
+ # πŸ’© 🩸 Asia/Kolkata 14:30
19
+ event_in_timezone_at_time_only: %r{πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{1,2}:\d{2})$},
20
+ # 🚽 πŸ“ +5 14:30, 🚽 πŸ“ +5:30 14:30
21
+ shift_set_at_time_only: /🚽\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{1,2}:\d{2})$/,
22
+ # 🚽 πŸ“ Asia/Kolkata 14:30
23
+ timezone_set_at_time_only: %r{🚽\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{1,2}:\d{2})$},
24
+ # 🚽 πŸ“ +2, 🚽 πŸ“ -3, 🚽 πŸ“ +5:30
25
+ shift_set: /🚽\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)$/,
26
+ # 🚽 πŸ“ +2 2025-01-15 14:30, 🚽 πŸ“ +5:30 2025-01-15 14:30
27
+ shift_set_at_time: %r{🚽\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
28
+ # 🚽 πŸ“ Europe/Rome, 🚽 πŸ“ PST
29
+ timezone_set: %r{🚽\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)$},
30
+ # 🚽 πŸ“ Asia/Kolkata 2025-01-15 14:30
31
+ timezone_set_at_time: %r{🚽\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
32
+ # πŸ’©, πŸ’© 🩸
33
+ event_now: /πŸ’©\s*([^+\-\dA-Za-z\s]*)$/,
34
+ # πŸ’© 🩸 +2, πŸ’© 🩸 -1, πŸ’© 🩸 +5:30
35
+ event_now_shifted: /πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)$/,
36
+ # πŸ’© 🩸 2025-01-15 14:30
37
+ event_at_time: %r{πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
38
+ # πŸ’© 🩸 +2 2025-01-15 14:30, πŸ’© 🩸 +5:30 2025-01-15 14:30
39
+ event_at_time_shifted: %r{πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
40
+ # πŸ’© 🩸 Europe/Rome, πŸ’© 🩸 PST
41
+ event_in_timezone: %r{πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s*$},
42
+ # πŸ’© 🩸 Asia/Kolkata 2025-01-15 14:30
43
+ event_in_timezone_at_time: %r{πŸ’©\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$}
22
44
  }
23
45
  @date_formats = [
24
- '%Y-%m-%d %H:%M:%S',
25
- '%Y-%m-%d %H:%M',
26
- '%Y/%m/%d %H:%M:%S',
27
- '%Y/%m/%d %H:%M'
46
+ "%Y-%m-%d %H:%M:%S",
47
+ "%Y-%m-%d %H:%M",
48
+ "%Y/%m/%d %H:%M:%S",
49
+ "%Y/%m/%d %H:%M",
50
+ "%H:%M"
28
51
  ]
29
52
  end
30
53
 
@@ -34,7 +57,7 @@ module ToiletTracker
34
57
  end
35
58
 
36
59
  def valid_shift?(shift)
37
- shift.is_a?(Integer) && shift.abs <= max_shift_hours
60
+ (shift.is_a?(Integer) || shift.is_a?(Float)) && shift.abs <= max_shift_hours
38
61
  end
39
62
 
40
63
  def whatsapp_message_pattern
@@ -42,7 +65,7 @@ module ToiletTracker
42
65
  end
43
66
 
44
67
  def edited_message_suffix
45
- 'β€Ž<This message was edited>'
68
+ "β€Ž<This message was edited>"
46
69
  end
47
70
  end
48
71
 
@@ -1,20 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'time'
4
- require 'active_support/core_ext/time'
3
+ require "time"
4
+ require "active_support/core_ext/time"
5
5
 
6
6
  module ToiletTracker
7
7
  module Core
8
8
  Message = Data.define(:timestamp, :sender, :content) do
9
9
  def initialize(timestamp:, sender:, content:)
10
10
  parsed_timestamp = case timestamp
11
- when Time, ActiveSupport::TimeWithZone
11
+ when Time, ActiveSupport::TimeWithZone
12
12
  timestamp
13
- when String
13
+ when String
14
14
  Time.parse(timestamp)
15
- else
15
+ else
16
16
  raise ArgumentError, "Invalid timestamp type: #{timestamp.class}"
17
- end
17
+ end
18
18
 
19
19
  super(
20
20
  timestamp: parsed_timestamp,
@@ -22,15 +22,15 @@ module ToiletTracker
22
22
  end
23
23
 
24
24
  # Result for toilet shift settings
25
- ToiletShiftResult = Data.define(:type, :status, :message, :shift, :effective_time, :error_details) do
26
- def initialize(type:, status:, message:, shift: nil, effective_time: nil, error_details: nil)
25
+ ToiletShiftResult = Data.define(:type, :status, :message, :shift, :effective_time, :metadata, :error_details) do
26
+ def initialize(type:, status:, message:, shift: nil, effective_time: nil, metadata: [], error_details: nil)
27
27
  super
28
28
  end
29
29
  end
30
30
 
31
31
  # Result for poop events
32
- PoopEventResult = Data.define(:type, :status, :message, :shift, :poop_time, :timezone, :error_details) do
33
- def initialize(type:, status:, message:, shift: nil, poop_time: nil, timezone: nil, error_details: nil)
32
+ PoopEventResult = Data.define(:type, :status, :message, :shift, :poop_time, :timezone, :metadata, :error_details) do
33
+ def initialize(type:, status:, message:, shift: nil, poop_time: nil, timezone: nil, metadata: [], error_details: nil)
34
34
  super
35
35
  end
36
36
  end
@@ -10,23 +10,29 @@ module ToiletTracker
10
10
  end
11
11
 
12
12
  def parse(input)
13
- raise NotImplementedError, 'Subclasses must implement #parse'
13
+ raise NotImplementedError, "Subclasses must implement #parse"
14
14
  end
15
15
 
16
16
  def valid?(input)
17
- raise NotImplementedError, 'Subclasses must implement #valid?'
17
+ raise NotImplementedError, "Subclasses must implement #valid?"
18
18
  end
19
19
 
20
20
  protected
21
21
 
22
22
  def clean_content(content)
23
- content.to_s.gsub(config.edited_message_suffix, '').strip
23
+ content.to_s.gsub(config.edited_message_suffix, "").strip
24
24
  end
25
25
 
26
26
  def parse_shift(shift_string)
27
27
  return nil if shift_string.nil? || shift_string.empty?
28
28
 
29
- shift = Integer(shift_string)
29
+ # Check if shift contains colon format (e.g., "+5:30", "-3:45")
30
+ shift = if shift_string.match?(/^[+-]?\d+:\d{2}$/)
31
+ parse_fractional_shift(shift_string)
32
+ else
33
+ Integer(shift_string)
34
+ end
35
+
30
36
  raise Core::InvalidShift, shift_string unless config.valid_shift?(shift)
31
37
 
32
38
  shift
@@ -34,26 +40,78 @@ module ToiletTracker
34
40
  raise Core::InvalidShift, shift_string
35
41
  end
36
42
 
37
- def parse_datetime(datetime_string)
43
+ # "+5:30" -> 5.5, "-3:45" -> -3.75, "+5:00" -> 5 (integer)
44
+ def parse_fractional_shift(shift_string)
45
+ match = shift_string.match(/^([+-])?(\d+):(\d{2})$/)
46
+ raise ArgumentError, "Invalid shift format: #{shift_string}" unless match
47
+
48
+ sign = (match[1] == "-") ? -1 : 1
49
+ hours = match[2].to_i
50
+ minutes = match[3].to_i
51
+
52
+ raise ArgumentError, "Invalid minutes: #{minutes}" if minutes > 59
53
+
54
+ decimal = hours + minutes / 60.0
55
+ minutes.zero? ? sign * hours : sign * decimal
56
+ end
57
+
58
+ def parse_datetime(datetime_string, reference_message = nil)
38
59
  return nil if datetime_string.nil? || datetime_string.empty?
39
60
 
61
+ time_only_result = try_parse_time_only(datetime_string, reference_message)
62
+ return time_only_result if time_only_result
63
+
40
64
  config.date_formats.each do |format|
65
+ next if format == "%H:%M"
41
66
  return config.default_timezone_object.strptime(datetime_string, format)
42
67
  rescue ArgumentError
43
68
  next
44
69
  end
45
70
 
46
- # Fallback to timezone-aware parse
47
71
  config.default_timezone_object.parse(datetime_string)
48
72
  rescue ArgumentError
49
73
  raise Core::InvalidDateTime, datetime_string
50
74
  end
51
75
 
76
+ def try_parse_time_only(datetime_string, reference_message)
77
+ return nil unless reference_message
78
+ return nil unless datetime_string.match?(/^\d{1,2}:\d{2}$/)
79
+
80
+ parsed_time = Time.strptime(datetime_string, "%H:%M")
81
+ date_part = reference_message.timestamp.to_date
82
+
83
+ config.default_timezone_object.local(
84
+ date_part.year,
85
+ date_part.month,
86
+ date_part.day,
87
+ parsed_time.hour,
88
+ parsed_time.min,
89
+ 0
90
+ )
91
+ rescue ArgumentError
92
+ nil
93
+ end
94
+
52
95
  def determine_status(event_time, message_time)
53
96
  return :error unless event_time && message_time
54
97
 
55
98
  time_diff = (message_time - event_time).abs
56
- time_diff > 30.days ? :alert : :ok
99
+ (time_diff > 30.days) ? :alert : :ok
100
+ end
101
+
102
+ def parse_metadata(metadata_string)
103
+ return [] if metadata_string.nil? || metadata_string.strip.empty?
104
+
105
+ metadata_string.grapheme_clusters
106
+ .reject { |char| char.match?(/\s/) }
107
+ .select { |char| metadata_emoji?(char) }
108
+ end
109
+
110
+ def metadata_emoji?(char)
111
+ return false if char.match?(/\A[\p{ASCII}]\z/)
112
+ return false if %w[πŸ’© 🚽].include?(char)
113
+
114
+ char.match?(/\p{Emoji}/) && !char.match?(/\A\d\z/)
57
115
  end
58
116
  end
59
117
  end
@@ -3,17 +3,20 @@
3
3
  module ToiletTracker
4
4
  module Parsers
5
5
  class MessageParser < BaseParser
6
+ attr_accessor :current_message
7
+
6
8
  def parse(message)
7
- return Core::Result.failure(ArgumentError.new('Message required')) unless message.is_a?(Core::Message)
8
- return Core::Result.failure(ArgumentError.new('Invalid message')) unless message.valid?
9
+ return Core::Result.failure(ArgumentError.new("Message required")) unless message.is_a?(Core::Message)
10
+ return Core::Result.failure(ArgumentError.new("Invalid message")) unless message.valid?
9
11
 
12
+ @current_message = message
10
13
  content = message.content.strip
11
14
 
12
- # Try to match patterns
13
15
  matched_result = try_pattern_matching(content, message)
16
+ @current_message = nil
14
17
  return matched_result if matched_result
15
18
 
16
- # Handle unmatched emoji content
19
+ @current_message = nil
17
20
  handle_unmatched_content(content)
18
21
  end
19
22
 
@@ -43,8 +46,8 @@ module ToiletTracker
43
46
  end
44
47
 
45
48
  def handle_unmatched_content(content)
46
- if content.include?('🚽') || content.include?('πŸ’©')
47
- error = Core::InvalidMessageFormat.new(content, 'valid emoji pattern')
49
+ if content.include?("🚽") || content.include?("πŸ’©")
50
+ error = Core::InvalidMessageFormat.new(content, "valid emoji pattern")
48
51
  return Core::Result.failure(error)
49
52
  end
50
53
 
@@ -53,32 +56,47 @@ module ToiletTracker
53
56
  end
54
57
 
55
58
  def parse_by_type(type, match, message)
56
- case type
57
- when :shift_set, :shift_set_at_time, :timezone_set
58
- parse_toilet_type(type, match, message)
59
+ mapped_type = map_time_only_pattern(type)
60
+
61
+ case mapped_type
62
+ when :shift_set, :shift_set_at_time
63
+ parse_toilet_type(mapped_type, match, message)
64
+ when :timezone_set
65
+ parse_toilet_timezone(match, message)
66
+ when :timezone_set_at_time
67
+ parse_toilet_timezone_with_time(match, message)
59
68
  when :event_now, :event_now_shifted, :event_at_time, :event_at_time_shifted,
60
69
  :event_in_timezone, :event_in_timezone_at_time
61
- parse_poop_type(type, match, message)
70
+ parse_poop_type(mapped_type, match, message)
62
71
  else
63
72
  raise Core::ParseError, "Unknown message type: #{type}"
64
73
  end
65
74
  end
66
75
 
76
+ def map_time_only_pattern(type)
77
+ case type
78
+ when :event_at_time_only then :event_at_time
79
+ when :event_at_time_only_shifted then :event_at_time_shifted
80
+ when :event_in_timezone_at_time_only then :event_in_timezone_at_time
81
+ when :shift_set_at_time_only then :shift_set_at_time
82
+ when :timezone_set_at_time_only then :timezone_set_at_time
83
+ else type
84
+ end
85
+ end
86
+
67
87
  def parse_toilet_type(type, match, message)
68
88
  case type
69
89
  when :shift_set
70
90
  parse_toilet_shift(match, message)
71
91
  when :shift_set_at_time
72
92
  parse_toilet_shift_with_time(match, message)
73
- when :timezone_set
74
- parse_toilet_timezone(match, message)
75
93
  end
76
94
  end
77
95
 
78
96
  def parse_poop_type(type, match, message)
79
97
  case type
80
98
  when :event_now
81
- parse_poop_live(message)
99
+ parse_poop_live(match, message)
82
100
  when :event_now_shifted
83
101
  parse_poop_live_shift(match, message)
84
102
  when :event_at_time
@@ -93,20 +111,23 @@ module ToiletTracker
93
111
  end
94
112
 
95
113
  def parse_toilet_shift(match, message)
96
- shift = parse_shift(match[1])
114
+ metadata = parse_metadata(match[1])
115
+ shift = parse_shift(match[2])
97
116
 
98
117
  Core::ToiletShiftResult.new(
99
118
  type: :shift_set,
100
119
  status: :ok,
101
120
  message: message,
102
121
  shift: shift,
103
- effective_time: message.timestamp
122
+ effective_time: message.timestamp,
123
+ metadata: metadata
104
124
  )
105
125
  end
106
126
 
107
127
  def parse_toilet_shift_with_time(match, message)
108
- shift = parse_shift(match[1])
109
- effective_time = parse_datetime(match[2])
128
+ metadata = parse_metadata(match[1])
129
+ shift = parse_shift(match[2])
130
+ effective_time = parse_datetime(match[3], @current_message)
110
131
 
111
132
  status = determine_status(effective_time, message.timestamp)
112
133
 
@@ -115,45 +136,84 @@ module ToiletTracker
115
136
  status: status,
116
137
  message: message,
117
138
  shift: shift,
118
- effective_time: effective_time
139
+ effective_time: effective_time,
140
+ metadata: metadata
119
141
  )
120
142
  end
121
143
 
122
144
  def parse_toilet_timezone(match, message)
123
- timezone_str = match[1]
145
+ metadata = parse_metadata(match[1])
146
+ timezone_str = match[2]
147
+ shift = calculate_shift_from_timezone(timezone_str)
124
148
 
125
- Core::TimezoneResult.new(
149
+ Core::ToiletShiftResult.new(
126
150
  type: :timezone_set,
127
151
  status: :ok,
128
152
  message: message,
129
- timezone: timezone_str
153
+ shift: shift,
154
+ effective_time: message.timestamp,
155
+ metadata: metadata
130
156
  )
131
157
  end
132
158
 
133
- def parse_poop_live(message)
159
+ def parse_toilet_timezone_with_time(match, message)
160
+ metadata = parse_metadata(match[1])
161
+ timezone_str = match[2]
162
+ datetime_str = match[3]
163
+
164
+ effective_time = parse_datetime(datetime_str, @current_message)
165
+ status = determine_status(effective_time, message.timestamp)
166
+ shift = calculate_shift_from_timezone(timezone_str)
167
+
168
+ Core::ToiletShiftResult.new(
169
+ type: :timezone_set_at_time,
170
+ status: status,
171
+ message: message,
172
+ shift: shift,
173
+ effective_time: effective_time,
174
+ metadata: metadata
175
+ )
176
+ end
177
+
178
+ def calculate_shift_from_timezone(timezone_str)
179
+ tz_object = ActiveSupport::TimeZone[timezone_str]
180
+ raise Core::InvalidTimezone, timezone_str unless tz_object
181
+
182
+ default_tz = config.default_timezone_object
183
+ shift = (tz_object.utc_offset - default_tz.utc_offset) / 3600.0
184
+ (shift == shift.to_i) ? shift.to_i : shift
185
+ end
186
+
187
+ def parse_poop_live(match, message)
188
+ metadata = parse_metadata(match[1])
189
+
134
190
  Core::PoopEventResult.new(
135
191
  type: :event_now,
136
192
  status: :ok,
137
193
  message: message,
138
194
  shift: 0,
139
- poop_time: message.timestamp
195
+ poop_time: message.timestamp,
196
+ metadata: metadata
140
197
  )
141
198
  end
142
199
 
143
200
  def parse_poop_live_shift(match, message)
144
- shift = parse_shift(match[1])
201
+ metadata = parse_metadata(match[1])
202
+ shift = parse_shift(match[2])
145
203
 
146
204
  Core::PoopEventResult.new(
147
205
  type: :event_now_shifted,
148
206
  status: :ok,
149
207
  message: message,
150
208
  shift: shift,
151
- poop_time: message.timestamp
209
+ poop_time: message.timestamp,
210
+ metadata: metadata
152
211
  )
153
212
  end
154
213
 
155
214
  def parse_poop_past(match, message)
156
- poop_time = parse_datetime(match[1])
215
+ metadata = parse_metadata(match[1])
216
+ poop_time = parse_datetime(match[2], @current_message)
157
217
  status = determine_status(poop_time, message.timestamp)
158
218
 
159
219
  Core::PoopEventResult.new(
@@ -161,13 +221,15 @@ module ToiletTracker
161
221
  status: status,
162
222
  message: message,
163
223
  shift: 0,
164
- poop_time: poop_time
224
+ poop_time: poop_time,
225
+ metadata: metadata
165
226
  )
166
227
  end
167
228
 
168
229
  def parse_poop_past_with_shift(match, message)
169
- shift = parse_shift(match[1])
170
- poop_time = parse_datetime(match[2])
230
+ metadata = parse_metadata(match[1])
231
+ shift = parse_shift(match[2])
232
+ poop_time = parse_datetime(match[3], @current_message)
171
233
  status = determine_status(poop_time, message.timestamp)
172
234
 
173
235
  Core::PoopEventResult.new(
@@ -175,25 +237,29 @@ module ToiletTracker
175
237
  status: status,
176
238
  message: message,
177
239
  shift: shift,
178
- poop_time: poop_time
240
+ poop_time: poop_time,
241
+ metadata: metadata
179
242
  )
180
243
  end
181
244
 
182
245
  def parse_poop_timezone(match, message)
183
- timezone_str = match[1]
246
+ metadata = parse_metadata(match[1])
247
+ timezone_str = match[2]
184
248
 
185
249
  Core::PoopEventResult.new(
186
250
  type: :event_in_timezone,
187
251
  status: :ok,
188
252
  message: message,
189
253
  poop_time: message.timestamp,
190
- timezone: timezone_str
254
+ timezone: timezone_str,
255
+ metadata: metadata
191
256
  )
192
257
  end
193
258
 
194
259
  def parse_poop_timezone_with_time(match, message)
195
- timezone_str = match[1]
196
- poop_time = parse_datetime(match[2])
260
+ metadata = parse_metadata(match[1])
261
+ timezone_str = match[2]
262
+ poop_time = parse_datetime(match[3], @current_message)
197
263
  status = determine_status(poop_time, message.timestamp)
198
264
 
199
265
  Core::PoopEventResult.new(
@@ -201,7 +267,8 @@ module ToiletTracker
201
267
  status: status,
202
268
  message: message,
203
269
  poop_time: poop_time,
204
- timezone: timezone_str
270
+ timezone: timezone_str,
271
+ metadata: metadata
205
272
  )
206
273
  end
207
274
  end
@@ -24,11 +24,11 @@ module ToiletTracker
24
24
  private
25
25
 
26
26
  def failure_for_invalid_format(line)
27
- Core::Result.failure(Core::InvalidMessageFormat.new(line, 'WhatsApp format'))
27
+ Core::Result.failure(Core::InvalidMessageFormat.new(line, "WhatsApp format"))
28
28
  end
29
29
 
30
30
  def failure_for_invalid_sender(line)
31
- Core::Result.failure(Core::InvalidMessageFormat.new(line, 'Empty sender'))
31
+ Core::Result.failure(Core::InvalidMessageFormat.new(line, "Empty sender"))
32
32
  end
33
33
 
34
34
  def build_message_from_match(match)
@@ -46,16 +46,16 @@ module ToiletTracker
46
46
  datetime_string = "#{day}/#{month}/#{full_year} #{time}"
47
47
 
48
48
  # Parse in the default timezone
49
- config.default_timezone_object.strptime(datetime_string, '%d/%m/%Y %H:%M:%S')
49
+ config.default_timezone_object.strptime(datetime_string, "%d/%m/%Y %H:%M:%S")
50
50
  rescue ArgumentError => e
51
51
  raise Core::InvalidDateTime, "#{day}/#{month}/#{year} #{time}: #{e.message}"
52
52
  end
53
53
 
54
54
  def clean_content(content)
55
- return '' if content.nil?
55
+ return "" if content.nil?
56
56
 
57
57
  # Remove the "This message was edited" suffix
58
- content.gsub(config.edited_message_suffix, '').strip
58
+ content.gsub(config.edited_message_suffix, "").strip
59
59
  end
60
60
  end
61
61
  end
@@ -9,7 +9,7 @@ module ToiletTracker
9
9
 
10
10
  def initialize(timezone_service = TimezoneService.new)
11
11
  @timezone_service = timezone_service
12
- @user_shifts = Hash.new { |h, k| h[k] = [default_shift_entry] }
12
+ @user_shifts = Hash.new { |h, k| h[k] = [ default_shift_entry ] }
13
13
  end
14
14
 
15
15
  def set_shift(user, shift, effective_time = Time.current, timezone = nil)
@@ -65,7 +65,7 @@ module ToiletTracker
65
65
  end
66
66
 
67
67
  def clear_history(user)
68
- @user_shifts[user] = [default_shift_entry]
68
+ @user_shifts[user] = [ default_shift_entry ]
69
69
  end
70
70
 
71
71
  def clear_all_history
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/time'
3
+ require "active_support/time"
4
4
 
5
5
  module ToiletTracker
6
6
  module Services
@@ -15,11 +15,11 @@ module ToiletTracker
15
15
  from_zone = resolve_timezone(from_timezone)
16
16
  to_zone = resolve_timezone(to_timezone)
17
17
 
18
- # Calculate offset difference in hours
19
18
  from_offset = from_zone.at(at_time).utc_offset
20
19
  to_offset = to_zone.at(at_time).utc_offset
21
20
 
22
- (to_offset - from_offset) / 3600
21
+ shift = (to_offset - from_offset) / 3600.0
22
+ (shift == shift.to_i) ? shift.to_i : shift
23
23
  end
24
24
 
25
25
  def apply_shift(time, shift_hours)
@@ -82,7 +82,7 @@ module ToiletTracker
82
82
  end
83
83
 
84
84
  def sample_times
85
- [Time.current, Time.current + 6.months]
85
+ [ Time.current, Time.current + 6.months ]
86
86
  end
87
87
 
88
88
  def add_zone_to_map(map, abbr, zone)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zip'
4
- require 'tempfile'
3
+ require "zip"
4
+ require "tempfile"
5
5
 
6
6
  module ToiletTracker
7
7
  module Utils
@@ -15,7 +15,7 @@ module ToiletTracker
15
15
  end
16
16
 
17
17
  def self.zip_file?(file_path)
18
- File.extname(file_path).downcase == '.zip'
18
+ File.extname(file_path).downcase == ".zip"
19
19
  end
20
20
 
21
21
  def initialize(zip_path)
@@ -26,14 +26,14 @@ module ToiletTracker
26
26
  def extract_chat_file
27
27
  chat_entries = find_chat_entries
28
28
 
29
- raise ChatFileNotFoundError, 'No _chat.txt file found in zip archive' if chat_entries.empty?
29
+ raise ChatFileNotFoundError, "No _chat.txt file found in zip archive" if chat_entries.empty?
30
30
 
31
31
  chat_entry = if chat_entries.size > 1
32
32
  # If multiple _chat.txt files, prefer the one in root or with shortest path
33
- chat_entries.min_by { |entry| entry.name.count('/') }
34
- else
33
+ chat_entries.min_by { |entry| entry.name.count("/") }
34
+ else
35
35
  chat_entries.first
36
- end
36
+ end
37
37
 
38
38
  extract_to_tempfile(chat_entry)
39
39
  end
@@ -64,7 +64,7 @@ module ToiletTracker
64
64
  end
65
65
 
66
66
  def extract_to_tempfile(entry)
67
- temp_file = Tempfile.new(['chat_export', '.txt'])
67
+ temp_file = Tempfile.new([ "chat_export", ".txt" ])
68
68
 
69
69
  begin
70
70
  Zip::File.open(@zip_path) do |_zip|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToiletTracker
4
- VERSION = '0.1.0'
4
+ VERSION = "0.2.1"
5
5
  end
@@ -1,20 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/time'
3
+ require "active_support/time"
4
4
 
5
- require_relative 'toilet_tracker/version'
6
- require_relative 'toilet_tracker/configuration'
7
- require_relative 'toilet_tracker/core/errors'
8
- require_relative 'toilet_tracker/core/message'
9
- require_relative 'toilet_tracker/core/result'
10
- require_relative 'toilet_tracker/core/parse_result'
11
- require_relative 'toilet_tracker/parsers/base_parser'
12
- require_relative 'toilet_tracker/parsers/whatsapp_parser'
13
- require_relative 'toilet_tracker/parsers/message_parser'
14
- require_relative 'toilet_tracker/services/timezone_service'
15
- require_relative 'toilet_tracker/services/shift_service'
16
- require_relative 'toilet_tracker/services/analysis_service'
17
- require_relative 'toilet_tracker/utils/zip_handler'
5
+ require_relative "toilet_tracker/version"
6
+ require_relative "toilet_tracker/configuration"
7
+ require_relative "toilet_tracker/core/errors"
8
+ require_relative "toilet_tracker/core/message"
9
+ require_relative "toilet_tracker/core/result"
10
+ require_relative "toilet_tracker/core/parse_result"
11
+ require_relative "toilet_tracker/parsers/base_parser"
12
+ require_relative "toilet_tracker/parsers/whatsapp_parser"
13
+ require_relative "toilet_tracker/parsers/message_parser"
14
+ require_relative "toilet_tracker/services/timezone_service"
15
+ require_relative "toilet_tracker/services/shift_service"
16
+ require_relative "toilet_tracker/services/analysis_service"
17
+ require_relative "toilet_tracker/utils/zip_handler"
18
18
 
19
19
  module ToiletTracker
20
20
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toilet_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NetherSoul
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: activesupport
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: 8.0.2.1
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: 8.2.0
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: 8.0.2.1
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: 8.2.0
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: csv
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -41,16 +47,22 @@ dependencies:
41
47
  name: rubyzip
42
48
  requirement: !ruby/object:Gem::Requirement
43
49
  requirements:
44
- - - "~>"
50
+ - - ">="
45
51
  - !ruby/object:Gem::Version
46
52
  version: 3.0.1
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: 3.3.0
47
56
  type: :runtime
48
57
  prerelease: false
49
58
  version_requirements: !ruby/object:Gem::Requirement
50
59
  requirements:
51
- - - "~>"
60
+ - - ">="
52
61
  - !ruby/object:Gem::Version
53
62
  version: 3.0.1
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: 3.3.0
54
66
  - !ruby/object:Gem::Dependency
55
67
  name: thor
56
68
  requirement: !ruby/object:Gem::Requirement
@@ -106,7 +118,6 @@ files:
106
118
  - ".overcommit.yml"
107
119
  - ".rspec"
108
120
  - ".rubocop.yml"
109
- - ".standard.yml"
110
121
  - CHANGELOG.md
111
122
  - CODE_OF_CONDUCT.md
112
123
  - LICENSE.txt
data/.standard.yml DELETED
@@ -1,2 +0,0 @@
1
- ruby_version: 3.4
2
- fix: true