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.
- checksums.yaml +7 -0
- data/.overcommit.yml +31 -0
- data/.rspec +3 -0
- data/.rubocop.yml +37 -0
- data/.standard.yml +2 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/PARSING_SPEC.md +361 -0
- data/README.md +253 -0
- data/Rakefile +12 -0
- data/STYLE_GUIDE.md +377 -0
- data/exe/toilet_tracker +419 -0
- data/lib/toilet_tracker/configuration.rb +64 -0
- data/lib/toilet_tracker/core/errors.rb +56 -0
- data/lib/toilet_tracker/core/message.rb +41 -0
- data/lib/toilet_tracker/core/parse_result.rb +45 -0
- data/lib/toilet_tracker/core/result.rb +46 -0
- data/lib/toilet_tracker/parsers/base_parser.rb +60 -0
- data/lib/toilet_tracker/parsers/message_parser.rb +209 -0
- data/lib/toilet_tracker/parsers/whatsapp_parser.rb +62 -0
- data/lib/toilet_tracker/services/analysis_service.rb +212 -0
- data/lib/toilet_tracker/services/shift_service.rb +89 -0
- data/lib/toilet_tracker/services/timezone_service.rb +93 -0
- data/lib/toilet_tracker/utils/zip_handler.rb +85 -0
- data/lib/toilet_tracker/version.rb +5 -0
- data/lib/toilet_tracker.rb +36 -0
- data/sig/toilet_tracker.rbs +4 -0
- metadata +159 -0
|
@@ -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
|