toilet_tracker 0.1.0

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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Parsers
5
+ class BaseParser
6
+ attr_reader :config
7
+
8
+ def initialize(config = ToiletTracker.configuration)
9
+ @config = config
10
+ end
11
+
12
+ def parse(input)
13
+ raise NotImplementedError, 'Subclasses must implement #parse'
14
+ end
15
+
16
+ def valid?(input)
17
+ raise NotImplementedError, 'Subclasses must implement #valid?'
18
+ end
19
+
20
+ protected
21
+
22
+ def clean_content(content)
23
+ content.to_s.gsub(config.edited_message_suffix, '').strip
24
+ end
25
+
26
+ def parse_shift(shift_string)
27
+ return nil if shift_string.nil? || shift_string.empty?
28
+
29
+ shift = Integer(shift_string)
30
+ raise Core::InvalidShift, shift_string unless config.valid_shift?(shift)
31
+
32
+ shift
33
+ rescue ArgumentError
34
+ raise Core::InvalidShift, shift_string
35
+ end
36
+
37
+ def parse_datetime(datetime_string)
38
+ return nil if datetime_string.nil? || datetime_string.empty?
39
+
40
+ config.date_formats.each do |format|
41
+ return config.default_timezone_object.strptime(datetime_string, format)
42
+ rescue ArgumentError
43
+ next
44
+ end
45
+
46
+ # Fallback to timezone-aware parse
47
+ config.default_timezone_object.parse(datetime_string)
48
+ rescue ArgumentError
49
+ raise Core::InvalidDateTime, datetime_string
50
+ end
51
+
52
+ def determine_status(event_time, message_time)
53
+ return :error unless event_time && message_time
54
+
55
+ time_diff = (message_time - event_time).abs
56
+ time_diff > 30.days ? :alert : :ok
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Parsers
5
+ class MessageParser < BaseParser
6
+ 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
+
10
+ content = message.content.strip
11
+
12
+ # Try to match patterns
13
+ matched_result = try_pattern_matching(content, message)
14
+ return matched_result if matched_result
15
+
16
+ # Handle unmatched emoji content
17
+ handle_unmatched_content(content)
18
+ end
19
+
20
+ def valid?(message)
21
+ return false unless message.is_a?(Core::Message) && message.valid?
22
+
23
+ content = message.content.strip
24
+ config.message_patterns.any? { |_, pattern| pattern.match?(content) }
25
+ end
26
+
27
+ private
28
+
29
+ def try_pattern_matching(content, message)
30
+ config.message_patterns.each do |type, pattern|
31
+ match = pattern.match(content)
32
+ next unless match
33
+
34
+ begin
35
+ result = parse_by_type(type, match, message)
36
+ return Core::Result.success(result)
37
+ rescue Core::Error => e
38
+ return Core::Result.failure(e)
39
+ end
40
+ end
41
+
42
+ nil
43
+ end
44
+
45
+ def handle_unmatched_content(content)
46
+ if content.include?('🚽') || content.include?('💩')
47
+ error = Core::InvalidMessageFormat.new(content, 'valid emoji pattern')
48
+ return Core::Result.failure(error)
49
+ end
50
+
51
+ # Not a toilet tracking message
52
+ Core::Result.success(nil)
53
+ end
54
+
55
+ 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
+ when :event_now, :event_now_shifted, :event_at_time, :event_at_time_shifted,
60
+ :event_in_timezone, :event_in_timezone_at_time
61
+ parse_poop_type(type, match, message)
62
+ else
63
+ raise Core::ParseError, "Unknown message type: #{type}"
64
+ end
65
+ end
66
+
67
+ def parse_toilet_type(type, match, message)
68
+ case type
69
+ when :shift_set
70
+ parse_toilet_shift(match, message)
71
+ when :shift_set_at_time
72
+ parse_toilet_shift_with_time(match, message)
73
+ when :timezone_set
74
+ parse_toilet_timezone(match, message)
75
+ end
76
+ end
77
+
78
+ def parse_poop_type(type, match, message)
79
+ case type
80
+ when :event_now
81
+ parse_poop_live(message)
82
+ when :event_now_shifted
83
+ parse_poop_live_shift(match, message)
84
+ when :event_at_time
85
+ parse_poop_past(match, message)
86
+ when :event_at_time_shifted
87
+ parse_poop_past_with_shift(match, message)
88
+ when :event_in_timezone
89
+ parse_poop_timezone(match, message)
90
+ when :event_in_timezone_at_time
91
+ parse_poop_timezone_with_time(match, message)
92
+ end
93
+ end
94
+
95
+ def parse_toilet_shift(match, message)
96
+ shift = parse_shift(match[1])
97
+
98
+ Core::ToiletShiftResult.new(
99
+ type: :shift_set,
100
+ status: :ok,
101
+ message: message,
102
+ shift: shift,
103
+ effective_time: message.timestamp
104
+ )
105
+ end
106
+
107
+ def parse_toilet_shift_with_time(match, message)
108
+ shift = parse_shift(match[1])
109
+ effective_time = parse_datetime(match[2])
110
+
111
+ status = determine_status(effective_time, message.timestamp)
112
+
113
+ Core::ToiletShiftResult.new(
114
+ type: :shift_set_at_time,
115
+ status: status,
116
+ message: message,
117
+ shift: shift,
118
+ effective_time: effective_time
119
+ )
120
+ end
121
+
122
+ def parse_toilet_timezone(match, message)
123
+ timezone_str = match[1]
124
+
125
+ Core::TimezoneResult.new(
126
+ type: :timezone_set,
127
+ status: :ok,
128
+ message: message,
129
+ timezone: timezone_str
130
+ )
131
+ end
132
+
133
+ def parse_poop_live(message)
134
+ Core::PoopEventResult.new(
135
+ type: :event_now,
136
+ status: :ok,
137
+ message: message,
138
+ shift: 0,
139
+ poop_time: message.timestamp
140
+ )
141
+ end
142
+
143
+ def parse_poop_live_shift(match, message)
144
+ shift = parse_shift(match[1])
145
+
146
+ Core::PoopEventResult.new(
147
+ type: :event_now_shifted,
148
+ status: :ok,
149
+ message: message,
150
+ shift: shift,
151
+ poop_time: message.timestamp
152
+ )
153
+ end
154
+
155
+ def parse_poop_past(match, message)
156
+ poop_time = parse_datetime(match[1])
157
+ status = determine_status(poop_time, message.timestamp)
158
+
159
+ Core::PoopEventResult.new(
160
+ type: :event_at_time,
161
+ status: status,
162
+ message: message,
163
+ shift: 0,
164
+ poop_time: poop_time
165
+ )
166
+ end
167
+
168
+ def parse_poop_past_with_shift(match, message)
169
+ shift = parse_shift(match[1])
170
+ poop_time = parse_datetime(match[2])
171
+ status = determine_status(poop_time, message.timestamp)
172
+
173
+ Core::PoopEventResult.new(
174
+ type: :event_at_time_shifted,
175
+ status: status,
176
+ message: message,
177
+ shift: shift,
178
+ poop_time: poop_time
179
+ )
180
+ end
181
+
182
+ def parse_poop_timezone(match, message)
183
+ timezone_str = match[1]
184
+
185
+ Core::PoopEventResult.new(
186
+ type: :event_in_timezone,
187
+ status: :ok,
188
+ message: message,
189
+ poop_time: message.timestamp,
190
+ timezone: timezone_str
191
+ )
192
+ end
193
+
194
+ def parse_poop_timezone_with_time(match, message)
195
+ timezone_str = match[1]
196
+ poop_time = parse_datetime(match[2])
197
+ status = determine_status(poop_time, message.timestamp)
198
+
199
+ Core::PoopEventResult.new(
200
+ type: :event_in_timezone_at_time,
201
+ status: status,
202
+ message: message,
203
+ poop_time: poop_time,
204
+ timezone: timezone_str
205
+ )
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Parsers
5
+ class WhatsappParser < BaseParser
6
+ def parse(line)
7
+ match = config.whatsapp_message_pattern.match(line)
8
+ return failure_for_invalid_format(line) unless match
9
+
10
+ begin
11
+ message = build_message_from_match(match)
12
+ return failure_for_invalid_sender(line) unless message.valid?
13
+
14
+ Core::Result.success(message)
15
+ rescue StandardError => e
16
+ Core::Result.failure(e)
17
+ end
18
+ end
19
+
20
+ def valid?(line)
21
+ config.whatsapp_message_pattern.match?(line)
22
+ end
23
+
24
+ private
25
+
26
+ def failure_for_invalid_format(line)
27
+ Core::Result.failure(Core::InvalidMessageFormat.new(line, 'WhatsApp format'))
28
+ end
29
+
30
+ def failure_for_invalid_sender(line)
31
+ Core::Result.failure(Core::InvalidMessageFormat.new(line, 'Empty sender'))
32
+ end
33
+
34
+ def build_message_from_match(match)
35
+ timestamp = parse_whatsapp_timestamp(match[:day], match[:month], match[:year], match[:time])
36
+ Core::Message.new(
37
+ timestamp: timestamp,
38
+ sender: match[:sender].strip,
39
+ content: clean_content(match[:content])
40
+ )
41
+ end
42
+
43
+ def parse_whatsapp_timestamp(day, month, year, time)
44
+ # Convert YY to full year (assuming 2000s)
45
+ full_year = "20#{year}"
46
+ datetime_string = "#{day}/#{month}/#{full_year} #{time}"
47
+
48
+ # Parse in the default timezone
49
+ config.default_timezone_object.strptime(datetime_string, '%d/%m/%Y %H:%M:%S')
50
+ rescue ArgumentError => e
51
+ raise Core::InvalidDateTime, "#{day}/#{month}/#{year} #{time}: #{e.message}"
52
+ end
53
+
54
+ def clean_content(content)
55
+ return '' if content.nil?
56
+
57
+ # Remove the "This message was edited" suffix
58
+ content.gsub(config.edited_message_suffix, '').strip
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Services
5
+ class AnalysisService
6
+ attr_reader :whatsapp_parser, :message_parser, :shift_service, :timezone_service
7
+
8
+ def initialize(
9
+ whatsapp_parser: Parsers::WhatsappParser.new,
10
+ message_parser: Parsers::MessageParser.new,
11
+ shift_service: ShiftService.new,
12
+ timezone_service: TimezoneService.new
13
+ )
14
+ @whatsapp_parser = whatsapp_parser
15
+ @message_parser = message_parser
16
+ @shift_service = shift_service
17
+ @timezone_service = timezone_service
18
+ end
19
+
20
+ def analyze_lines(lines)
21
+ # Parse all lines first
22
+ results = lines.filter_map { |line| analyze_line(line) }
23
+
24
+ # Apply retroactive shifts
25
+ apply_retroactive_shifts_to_results(results)
26
+ end
27
+
28
+ def analyze_line(line)
29
+ # Parse WhatsApp line format first
30
+ whatsapp_result = whatsapp_parser.parse(line)
31
+ return create_error_result(whatsapp_result.error, line) if whatsapp_result.failure?
32
+
33
+ message = whatsapp_result.value
34
+
35
+ # Parse message content for toilet tracking commands
36
+ message_result = message_parser.parse(message)
37
+ return create_error_result(message_result.error, line) if message_result.failure?
38
+
39
+ parsed_result = message_result.value
40
+ return nil unless parsed_result # Not a toilet tracking message
41
+
42
+ # Process the parsed result
43
+ process_parsed_result(parsed_result)
44
+ end
45
+
46
+ def analyze_messages(messages, apply_retroactive_shifts: true)
47
+ results = []
48
+
49
+ # First pass: process all messages and build shift history
50
+ messages.each do |message|
51
+ message_result = message_parser.parse(message)
52
+ next if message_result.failure? || message_result.value.nil?
53
+
54
+ result = process_parsed_result(message_result.value)
55
+ results << result if result
56
+ end
57
+
58
+ # Second pass: apply retroactive shifts to poop events if enabled
59
+ results = apply_retroactive_shifts_to_results(results) if apply_retroactive_shifts
60
+
61
+ results
62
+ end
63
+
64
+ private
65
+
66
+ def process_parsed_result(parsed_result)
67
+ case parsed_result
68
+ when Core::ToiletShiftResult
69
+ process_toilet_shift(parsed_result)
70
+ when Core::TimezoneResult
71
+ process_timezone_setting(parsed_result)
72
+ when Core::PoopEventResult
73
+ process_poop_event(parsed_result)
74
+ else
75
+ parsed_result
76
+ end
77
+ end
78
+
79
+ def process_toilet_shift(result)
80
+ user = result.message.sender
81
+ effective_time = result.effective_time || result.message.timestamp
82
+
83
+ # Use retroactive shift if effective time is earlier than message time
84
+ if result.effective_time && result.effective_time < result.message.timestamp
85
+ shift_service.set_retroactive_shift(user, result.shift, effective_time)
86
+ else
87
+ shift_service.set_shift(user, result.shift, effective_time)
88
+ end
89
+
90
+ result
91
+ rescue Core::Error => e
92
+ create_error_from_result(result, e)
93
+ end
94
+
95
+ def process_timezone_setting(result)
96
+ user = result.message.sender
97
+ effective_time = result.message.timestamp
98
+
99
+ begin
100
+ shift_service.set_timezone(user, result.timezone, effective_time)
101
+
102
+ # Calculate the actual shift for the result
103
+ shift = timezone_service.calculate_shift(
104
+ ToiletTracker.configuration.default_timezone,
105
+ result.timezone,
106
+ effective_time
107
+ )
108
+
109
+ Core::ToiletShiftResult.new(
110
+ type: :timezone_shift,
111
+ status: :ok,
112
+ message: result.message,
113
+ shift: shift,
114
+ effective_time: effective_time
115
+ )
116
+ rescue Core::Error => e
117
+ create_error_from_result(result, e)
118
+ end
119
+ end
120
+
121
+ def process_poop_event(result)
122
+ user = result.message.sender
123
+
124
+ case result.type
125
+ when :event_now
126
+ process_poop_live(result, user)
127
+ when :event_now_shifted
128
+ process_poop_live_shift(result)
129
+ when :event_in_timezone
130
+ process_poop_timezone(result, user)
131
+ else
132
+ process_poop_with_explicit_time(result, user)
133
+ end
134
+ end
135
+
136
+ def process_poop_live(result, user)
137
+ active_shift = shift_service.get_active_shift(user, result.message.timestamp)
138
+ adjusted_time = timezone_service.apply_shift(result.message.timestamp, active_shift)
139
+ result.with(shift: active_shift, poop_time: adjusted_time)
140
+ end
141
+
142
+ def process_poop_live_shift(result)
143
+ adjusted_time = timezone_service.apply_shift(result.message.timestamp, result.shift)
144
+ result.with(poop_time: adjusted_time)
145
+ end
146
+
147
+ def process_poop_timezone(result, _user)
148
+ shift = timezone_service.calculate_shift(
149
+ ToiletTracker.configuration.default_timezone,
150
+ result.timezone,
151
+ result.message.timestamp
152
+ )
153
+ adjusted_time = timezone_service.apply_shift(result.message.timestamp, shift)
154
+ result.with(shift: shift, poop_time: adjusted_time)
155
+ rescue Core::Error => e
156
+ create_error_from_result(result, e)
157
+ end
158
+
159
+ def process_poop_with_explicit_time(result, user)
160
+ case result.type
161
+ when :event_at_time
162
+ # Record active shift for reference
163
+ active_shift = shift_service.get_active_shift(user, result.poop_time)
164
+ result.with(shift: active_shift)
165
+ when :event_at_time_shifted, :event_in_timezone_at_time
166
+ # These have decorative shifts/timezones - preserve them as-is
167
+ # poop_time remains unchanged, shift/timezone are for display only
168
+ result
169
+ else
170
+ result
171
+ end
172
+ end
173
+
174
+ def apply_retroactive_shifts_to_results(results)
175
+ # Only adjust poop_live events that don't have explicit shifts
176
+ results.map do |result|
177
+ next result unless should_apply_retroactive_shift?(result)
178
+
179
+ user = result.message.sender
180
+ correct_shift = shift_service.get_active_shift(user, result.message.timestamp)
181
+
182
+ if correct_shift == result.shift
183
+ result
184
+ else
185
+ adjusted_time = timezone_service.apply_shift(result.message.timestamp, correct_shift)
186
+ result.with(shift: correct_shift, poop_time: adjusted_time)
187
+ end
188
+ end
189
+ end
190
+
191
+ def should_apply_retroactive_shift?(result)
192
+ result.is_a?(Core::PoopEventResult) &&
193
+ result.type == :event_now &&
194
+ result.status != :error
195
+ end
196
+
197
+ def create_error_result(error, original_line = nil)
198
+ Core::ParseResult.new(
199
+ type: :parse_error,
200
+ status: :error,
201
+ message: nil,
202
+ error_details: error.message,
203
+ original_content: original_line
204
+ )
205
+ end
206
+
207
+ def create_error_from_result(result, error)
208
+ result.with(status: :error, error_details: error.message)
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Services
5
+ class ShiftService
6
+ ShiftEntry = Data.define(:time, :shift, :timezone)
7
+
8
+ attr_reader :timezone_service
9
+
10
+ def initialize(timezone_service = TimezoneService.new)
11
+ @timezone_service = timezone_service
12
+ @user_shifts = Hash.new { |h, k| h[k] = [default_shift_entry] }
13
+ end
14
+
15
+ def set_shift(user, shift, effective_time = Time.current, timezone = nil)
16
+ validate_shift!(shift)
17
+
18
+ entry = ShiftEntry.new(
19
+ time: effective_time,
20
+ shift: shift,
21
+ timezone: timezone
22
+ )
23
+
24
+ @user_shifts[user] << entry
25
+ @user_shifts[user].sort_by!(&:time)
26
+ end
27
+
28
+ def set_retroactive_shift(user, shift, effective_time, timezone = nil)
29
+ validate_shift!(shift)
30
+
31
+ entry = ShiftEntry.new(
32
+ time: effective_time,
33
+ shift: shift,
34
+ timezone: timezone
35
+ )
36
+
37
+ # Remove any shifts that were set after this effective time
38
+ @user_shifts[user].reject! { |existing| existing.time > effective_time }
39
+
40
+ @user_shifts[user] << entry
41
+ @user_shifts[user].sort_by!(&:time)
42
+ end
43
+
44
+ def set_timezone(user, timezone_spec, effective_time = Time.current)
45
+ timezone = timezone_service.resolve_timezone(timezone_spec)
46
+ shift = timezone_service.calculate_shift(
47
+ ToiletTracker.configuration.default_timezone,
48
+ timezone,
49
+ effective_time
50
+ )
51
+
52
+ set_shift(user, shift, effective_time, timezone_spec)
53
+ end
54
+
55
+ def get_active_shift(user, at_time = Time.current)
56
+ history = @user_shifts[user]
57
+
58
+ # Find the most recent shift that was set before or at the given time
59
+ relevant_entry = history.reverse_each.find { |entry| entry.time <= at_time }
60
+ relevant_entry&.shift || 0
61
+ end
62
+
63
+ def get_shift_history(user)
64
+ @user_shifts[user].dup
65
+ end
66
+
67
+ def clear_history(user)
68
+ @user_shifts[user] = [default_shift_entry]
69
+ end
70
+
71
+ def clear_all_history
72
+ @user_shifts.clear
73
+ end
74
+
75
+ private
76
+
77
+ def validate_shift!(shift)
78
+ return if ToiletTracker.configuration.valid_shift?(shift)
79
+
80
+ raise Core::InvalidShift, shift
81
+ end
82
+
83
+ def default_shift_entry
84
+ # Very old timestamp to ensure it's always the earliest
85
+ ShiftEntry.new(time: Time.at(0), shift: 0, timezone: nil)
86
+ end
87
+ end
88
+ end
89
+ end