kaal 0.2.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/models/kaal/cron_definition.rb +71 -0
  6. data/app/models/kaal/cron_dispatch.rb +50 -0
  7. data/app/models/kaal/cron_lock.rb +38 -0
  8. data/config/locales/en.yml +46 -0
  9. data/lib/generators/kaal/install/install_generator.rb +67 -0
  10. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
  11. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
  12. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
  13. data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
  14. data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
  15. data/lib/kaal/backend/adapter.rb +147 -0
  16. data/lib/kaal/backend/dispatch_logging.rb +79 -0
  17. data/lib/kaal/backend/memory_adapter.rb +99 -0
  18. data/lib/kaal/backend/mysql_adapter.rb +170 -0
  19. data/lib/kaal/backend/postgres_adapter.rb +134 -0
  20. data/lib/kaal/backend/redis_adapter.rb +145 -0
  21. data/lib/kaal/backend/sqlite_adapter.rb +116 -0
  22. data/lib/kaal/configuration.rb +231 -0
  23. data/lib/kaal/coordinator.rb +437 -0
  24. data/lib/kaal/cron_humanizer.rb +182 -0
  25. data/lib/kaal/cron_utils.rb +233 -0
  26. data/lib/kaal/definition/database_engine.rb +45 -0
  27. data/lib/kaal/definition/memory_engine.rb +61 -0
  28. data/lib/kaal/definition/redis_engine.rb +93 -0
  29. data/lib/kaal/definition/registry.rb +46 -0
  30. data/lib/kaal/dispatch/database_engine.rb +94 -0
  31. data/lib/kaal/dispatch/memory_engine.rb +99 -0
  32. data/lib/kaal/dispatch/redis_engine.rb +103 -0
  33. data/lib/kaal/dispatch/registry.rb +62 -0
  34. data/lib/kaal/idempotency_key_generator.rb +26 -0
  35. data/lib/kaal/railtie.rb +183 -0
  36. data/lib/kaal/rake_tasks.rb +184 -0
  37. data/lib/kaal/register_conflict_support.rb +54 -0
  38. data/lib/kaal/registry.rb +242 -0
  39. data/lib/kaal/scheduler_config_error.rb +6 -0
  40. data/lib/kaal/scheduler_file_loader.rb +316 -0
  41. data/lib/kaal/scheduler_hash_transform.rb +40 -0
  42. data/lib/kaal/scheduler_placeholder_support.rb +80 -0
  43. data/lib/kaal/version.rb +10 -0
  44. data/lib/kaal.rb +571 -0
  45. data/lib/tasks/kaal_tasks.rake +10 -0
  46. metadata +142 -0
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require 'fugit'
9
+
10
+ module Kaal
11
+ ##
12
+ # Coordinator manages the main scheduler loop that calculates due fire times
13
+ # and dispatches cron work safely across multiple nodes using backend lease coordination.
14
+ #
15
+ # The coordinator:
16
+ # 1. Runs a background thread on tick_interval
17
+ # 2. For each registered cron, calculates due fire times within the window
18
+ # 3. Attempts to acquire a distributed lease for each due time
19
+ # 4. Calls the enqueue callback if the lease is acquired
20
+ # 5. Supports graceful shutdown and re-entrancy for testing
21
+ #
22
+ # @example Start the coordinator
23
+ # coordinator = Kaal::Coordinator.new
24
+ # coordinator.start!
25
+ #
26
+ # @example Manual tick execution (for testing)
27
+ # coordinator.tick!
28
+ #
29
+ # @example Stop the coordinator
30
+ # coordinator.stop!
31
+ class Coordinator
32
+ ##
33
+ # Initialize a new Coordinator instance.
34
+ #
35
+ # @param configuration [Configuration] the scheduler configuration
36
+ # @param registry [Registry] the registered crons registry
37
+ def initialize(configuration:, registry:)
38
+ @configuration = configuration
39
+ @registry = registry
40
+ @thread = nil
41
+ @running = false
42
+ @stop_requested = false
43
+ @mutex = Mutex.new
44
+ @tick_cv = ConditionVariable.new
45
+ end
46
+
47
+ ##
48
+ # Start the coordinator background thread.
49
+ #
50
+ # @return [Thread] the started thread, or nil if already running
51
+ # @safe
52
+ def start!
53
+ @mutex.synchronize do
54
+ return nil if @running
55
+
56
+ # Run recovery before starting the main loop
57
+ recover_missed_runs
58
+
59
+ @running = true
60
+ @stop_requested = false
61
+ @thread = Thread.new { run_loop }
62
+ @thread.abort_on_exception = true
63
+ return @thread
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Stop the coordinator gracefully.
69
+ #
70
+ # Signals the coordinator to stop after the current tick completes,
71
+ # then waits for the thread to finish.
72
+ #
73
+ # @param timeout [Integer] seconds to wait for graceful shutdown (default: 30)
74
+ # @return [Boolean] true if stopped, false if timeout
75
+ # @safe
76
+ def stop!(timeout: 30) # rubocop:disable Naming/PredicateMethod
77
+ request_stop
78
+
79
+ # Wait for thread to finish outside the lock
80
+ result = @thread&.join(timeout)
81
+
82
+ # If we had a thread and join timed out, thread is still alive
83
+ return false if @thread && result.nil?
84
+
85
+ @thread = nil
86
+ @mutex.synchronize { @running = false }
87
+
88
+ true
89
+ end
90
+
91
+ ##
92
+ # Check if the coordinator is currently running.
93
+ #
94
+ # @return [Boolean] true if running, false otherwise
95
+ def running?
96
+ @mutex.synchronize { @running }
97
+ end
98
+
99
+ ##
100
+ # Restart the coordinator (stop then start).
101
+ #
102
+ # @return [Thread] the started thread
103
+ # @safe
104
+ def restart!
105
+ stop!
106
+ start!
107
+ end
108
+
109
+ ##
110
+ # Execute a single tick manually.
111
+ #
112
+ # This is useful for testing and Rake tasks that want to trigger
113
+ # the scheduler without running the background loop.
114
+ #
115
+ # @return [void]
116
+ # @safe
117
+ def tick!
118
+ execute_tick
119
+ end
120
+
121
+ ##
122
+ # Reset coordinator state for re-entrancy in tests.
123
+ #
124
+ # Stops any running thread before clearing state to avoid orphaning it.
125
+ # Raises an error if the thread cannot be stopped within the timeout.
126
+ #
127
+ # @return [void]
128
+ # @raise [RuntimeError] if stop! times out
129
+ # @safe
130
+ def reset!
131
+ # Stop any running thread first to prevent orphaned threads
132
+ if running?
133
+ stopped = stop!
134
+ raise 'Failed to stop coordinator thread within timeout' unless stopped
135
+ end
136
+
137
+ # Now safe to reset all state
138
+ @mutex.synchronize do
139
+ @running = false
140
+ @stop_requested = false
141
+ @thread = nil
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def request_stop
148
+ @mutex.synchronize do
149
+ return unless @running
150
+
151
+ @stop_requested = true
152
+ @tick_cv.signal
153
+ end
154
+ end
155
+
156
+ def run_loop
157
+ loop do
158
+ break if stop_requested?
159
+
160
+ execute_tick
161
+ sleep_until_next_tick
162
+ end
163
+ ensure
164
+ @mutex.synchronize { @running = false }
165
+ end
166
+
167
+ def stop_requested?
168
+ @mutex.synchronize { @stop_requested }
169
+ end
170
+
171
+ def execute_tick
172
+ each_enabled_entry do |entry|
173
+ calculate_and_dispatch_due_times(entry)
174
+ end
175
+ rescue StandardError => e
176
+ # Log error but continue the loop
177
+ @configuration.logger&.error("Kaal coordinator tick failed: #{e.message}")
178
+ end
179
+
180
+ def calculate_and_dispatch_due_times(entry)
181
+ now = Time.current
182
+ window_start = now - @configuration.window_lookback
183
+ window_end = now + @configuration.window_lookahead
184
+
185
+ # Parse cron expression using fugit
186
+ cron = parse_cron(entry.cron)
187
+ return unless cron
188
+
189
+ # Find all occurrences within the window
190
+ occurrences = find_occurrences(cron, window_start, window_end)
191
+ @configuration.logger&.debug("Coordinator: Found #{occurrences.length} occurrences for #{entry.key} in window [#{window_start}, #{window_end}]")
192
+
193
+ # For each occurrence that's due (in the past or now), try to dispatch
194
+ occurrences.each do |fire_time|
195
+ dispatch_if_due(entry, fire_time, now)
196
+ end
197
+ end
198
+
199
+ def parse_cron(cron_expression)
200
+ result = Fugit.parse_cron(cron_expression)
201
+ raise ArgumentError, "Invalid cron expression: #{cron_expression}" unless result
202
+
203
+ result
204
+ rescue ArgumentError => e
205
+ @configuration.logger&.warn("Failed to parse cron expression '#{cron_expression}': #{e.message}")
206
+ nil
207
+ end
208
+
209
+ def find_occurrences(cron, start_time, end_time)
210
+ # Use fugit to find all occurrences between start and end times
211
+ occurrences = []
212
+ current_time = start_time
213
+
214
+ while current_time <= end_time
215
+ next_occurrence = cron.next_time(current_time)
216
+ break unless next_occurrence
217
+
218
+ break if next_occurrence > end_time
219
+
220
+ occurrences << next_occurrence
221
+ current_time = next_occurrence + 1.second # Move past this occurrence to find the next
222
+ end
223
+
224
+ occurrences
225
+ rescue StandardError => e
226
+ @configuration.logger&.error("Failed to calculate occurrences: #{e.message}")
227
+ []
228
+ end
229
+
230
+ def dispatch_if_due(entry, fire_time, now)
231
+ # Only dispatch if fire_time is in the past or now
232
+ return if fire_time > now
233
+
234
+ logger = @configuration.logger
235
+ cron_key = entry.key
236
+
237
+ # Generate a unique backend coordination key for this fire time
238
+ lock_key = generate_lock_key(cron_key, fire_time)
239
+
240
+ # Try to acquire the coordination lease
241
+ if acquire_lock(lock_key)
242
+ dispatch_work(entry, fire_time)
243
+ else
244
+ logger&.debug("Failed to acquire lock for #{lock_key}")
245
+ end
246
+ rescue StandardError => e
247
+ cron_key ||= 'unknown'
248
+ logger&.error("Error dispatching work for #{cron_key}: #{e.message}")
249
+ end
250
+
251
+ ##
252
+ # Recover missed cron runs after downtime.
253
+ #
254
+ # Looks back over the recovery window to find cron jobs that should have executed
255
+ # but were missed due to downtime. Uses dispatch records (if enabled) to skip
256
+ # already-dispatched jobs, and relies on lease coordination for duplicate prevention.
257
+ #
258
+ # @return [void]
259
+ def recover_missed_runs
260
+ return unless @configuration.enable_dispatch_recovery
261
+
262
+ # Add random jitter to reduce lock contention when multiple nodes restart simultaneously
263
+ jitter = rand(0..@configuration.recovery_startup_jitter)
264
+ sleep(jitter) if jitter.positive?
265
+
266
+ current_time = Time.current
267
+ recovery_window = @configuration.recovery_window
268
+ recovery_start = current_time - recovery_window
269
+ recovery_end = current_time
270
+
271
+ logger = @configuration.logger
272
+ logger&.info("Starting missed-run recovery for window: #{recovery_start} to #{recovery_end}")
273
+
274
+ total_recovered = 0
275
+ each_enabled_entry do |entry|
276
+ recovered = recover_entry(entry, recovery_start, recovery_end)
277
+ total_recovered += recovered
278
+ end
279
+
280
+ logger&.info("Missed-run recovery completed: attempted #{total_recovered} dispatches")
281
+
282
+ # Clean up old dispatch records after recovery completes
283
+ cleanup_old_dispatch_records(recovery_window)
284
+ rescue StandardError => e
285
+ logger&.error("Error during missed-run recovery: #{e.message}")
286
+ end
287
+
288
+ ##
289
+ # Recover missed runs for a single cron entry.
290
+ #
291
+ # @param entry [Kaal::Registry::Entry] the cron job entry
292
+ # @param start_time [Time] the start of the recovery window
293
+ # @param end_time [Time] the end of the recovery window
294
+ # @return [Integer] number of occurrences attempted to dispatch
295
+ def recover_entry(entry, start_time, end_time)
296
+ logger = @configuration.logger
297
+ entry_key = entry.key
298
+ cron = parse_cron(entry.cron)
299
+ return 0 unless cron
300
+
301
+ occurrences = find_occurrences(cron, start_time, end_time)
302
+
303
+ # Filter out already-dispatched runs if dispatch logging is enabled
304
+ occurrences.reject! { |fire_time| already_dispatched?(entry_key, fire_time) } if @configuration.enable_log_dispatch_registry
305
+ occurrences_size = occurrences.size
306
+ logger&.info("Recovering #{occurrences_size} missed runs for #{entry_key}")
307
+
308
+ # Attempt to dispatch each missed occurrence
309
+ occurrences.each do |fire_time|
310
+ dispatch_if_due(entry, fire_time, Time.current)
311
+ end
312
+
313
+ occurrences_size
314
+ rescue StandardError => e
315
+ logger&.error("Error recovering entry #{entry_key}: #{e.message}")
316
+ 0
317
+ end
318
+
319
+ ##
320
+ # Clean up old dispatch records to prevent database bloat.
321
+ #
322
+ # Called after recovery completes. Deletes dispatch records older than
323
+ # the recovery window, since they are no longer needed for future recovery.
324
+ #
325
+ # @param recovery_window [Integer] seconds - records older than this are deleted
326
+ # @return [void]
327
+ def cleanup_old_dispatch_records(recovery_window)
328
+ logger = @configuration.logger
329
+ adapter = @configuration.backend
330
+ return if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
331
+
332
+ registry = adapter.dispatch_registry
333
+ return unless registry.respond_to?(:cleanup)
334
+
335
+ deleted_count = registry.cleanup(recovery_window: recovery_window)
336
+ logger&.debug("Cleaned up #{deleted_count} old dispatch records") if deleted_count.positive?
337
+ rescue StandardError => e
338
+ logger&.warn("Error cleaning up old dispatch records: #{e.message}")
339
+ end
340
+
341
+ ##
342
+ # Check if a cron job was already dispatched.
343
+ #
344
+ # @param key [String] the cron job key
345
+ # @param fire_time [Time] the fire time to check
346
+ # @return [Boolean] true if already dispatched, false otherwise
347
+ def already_dispatched?(key, fire_time)
348
+ adapter = @configuration.backend
349
+ return false if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
350
+
351
+ adapter.dispatch_registry.dispatched?(key, fire_time)
352
+ rescue StandardError => e
353
+ @configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
354
+ false
355
+ end
356
+
357
+ def acquire_lock(lock_key)
358
+ backend = @configuration.backend
359
+ logger = @configuration.logger
360
+
361
+ # No adapter = no locking (dev/test)
362
+ return true unless backend
363
+
364
+ backend.acquire(lock_key, @configuration.lease_ttl)
365
+ rescue StandardError => e
366
+ logger&.error("Lock acquisition failed for #{lock_key}: #{e.message}")
367
+ false
368
+ end
369
+
370
+ def each_enabled_entry(&)
371
+ use_registry_entries = false
372
+ logger = @configuration.logger
373
+ warn_iteration_failure = ->(target, error) { logger&.warn("Failed to iterate #{target}: #{error.message}") }
374
+
375
+ begin
376
+ definition_registry = Kaal.definition_registry
377
+ return each_registry_entry(&) unless definition_registry
378
+
379
+ definitions = definition_registry.enabled_definitions || []
380
+ use_registry_entries = definitions.empty? && definition_registry.all_definitions.to_a.empty?
381
+
382
+ definitions
383
+ .filter_map { |definition| build_entry_from_definition(definition) }
384
+ .each(&)
385
+ rescue StandardError => e
386
+ warn_iteration_failure.call('enabled definitions', e)
387
+ use_registry_entries = true
388
+ end
389
+
390
+ each_registry_entry(&) if use_registry_entries
391
+ end
392
+
393
+ def build_entry_from_definition(definition)
394
+ key = definition[:key]
395
+ callback_entry = @registry.find(key)
396
+ unless callback_entry&.enqueue
397
+ @configuration.logger&.warn("No enqueue callback registered for definition '#{key}', skipping")
398
+ return nil
399
+ end
400
+
401
+ Registry::Entry.new(key: key, cron: definition[:cron], enqueue: callback_entry.enqueue).freeze
402
+ end
403
+
404
+ def dispatch_work(entry, fire_time)
405
+ # Call the enqueue callback with fire_time and idempotency_key
406
+ cron_key = entry.key
407
+ logger = @configuration.logger
408
+
409
+ idempotency_key = generate_idempotency_key(cron_key, fire_time)
410
+ entry.enqueue.call(fire_time:, idempotency_key:)
411
+ logger&.debug("Dispatched work for #{cron_key} at #{fire_time}")
412
+ rescue StandardError => e
413
+ logger&.error("Work dispatch failed for #{cron_key}: #{e.message}")
414
+ end
415
+
416
+ def generate_idempotency_key(cron_key, fire_time)
417
+ Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
418
+ end
419
+
420
+ def generate_lock_key(cron_key, fire_time)
421
+ namespace = @configuration.namespace || 'kaal'
422
+ "#{namespace}:dispatch:#{cron_key}:#{fire_time.to_i}"
423
+ end
424
+
425
+ def sleep_until_next_tick
426
+ @mutex.synchronize do
427
+ @tick_cv.wait(@mutex, @configuration.tick_interval)
428
+ end
429
+ rescue StandardError => e
430
+ @configuration.logger&.error("Sleep interrupted: #{e.message}")
431
+ end
432
+
433
+ def each_registry_entry(&)
434
+ @registry.each(&)
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require 'fugit'
9
+ require 'i18n'
10
+
11
+ module Kaal
12
+ ##
13
+ # Human-friendly cron phrase generation with i18n support.
14
+ module CronHumanizer
15
+ MACRO_PHRASES = {
16
+ '@yearly' => 'phrases.yearly',
17
+ '@monthly' => 'phrases.monthly',
18
+ '@weekly' => 'phrases.weekly',
19
+ '@daily' => 'phrases.daily',
20
+ '@hourly' => 'phrases.hourly'
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ def to_human(expression, locale: nil)
26
+ normalized = CronUtils.safe_normalize_expression(expression)
27
+ raise ArgumentError, CronUtils.invalid_expression_error_message('') unless normalized
28
+ raise ArgumentError, CronUtils.invalid_expression_error_message(normalized) if normalized.empty?
29
+
30
+ resolved_locale = locale || I18n.locale
31
+ I18n.with_locale(resolved_locale) do
32
+ humanized = humanize_expression(normalized)
33
+ return humanized unless humanized.to_s.strip.empty?
34
+
35
+ translate_phrase('phrases.cron_expression', expression: normalized)
36
+ end
37
+ end
38
+
39
+ def humanize_expression(normalized)
40
+ error_message = CronUtils.invalid_expression_error_message(normalized)
41
+ return humanize_macro(normalized) if macro?(normalized)
42
+
43
+ cron = Fugit.parse_cron(normalized)
44
+ return humanize_cron(cron) if cron
45
+
46
+ raise ArgumentError, error_message
47
+ end
48
+ private_class_method :humanize_expression
49
+
50
+ def humanize_macro(expression)
51
+ macro = expression.downcase
52
+ raise ArgumentError, CronUtils.unsupported_macro_error_message(expression) unless CronUtils::MACRO_MAP.key?(macro)
53
+
54
+ canonical_macro = CronUtils::CANONICAL_MACROS.fetch(CronUtils::MACRO_MAP.fetch(macro), macro)
55
+ phrase_key = MACRO_PHRASES.fetch(canonical_macro, nil)
56
+ return translate_phrase(phrase_key) if phrase_key
57
+
58
+ translate_phrase('phrases.cron_expression', expression: expression)
59
+ end
60
+ private_class_method :humanize_macro
61
+
62
+ def humanize_cron(cron)
63
+ canonical = cron.to_cron_s
64
+ macro = CronUtils::CANONICAL_MACROS[canonical]
65
+ return humanize_macro(macro) if macro
66
+
67
+ interval_phrase = every_minute_interval_phrase(cron)
68
+ return interval_phrase if interval_phrase
69
+
70
+ weekday_phrase = at_time_weekday_phrase(cron)
71
+ return weekday_phrase if weekday_phrase
72
+
73
+ daily_phrase = at_time_daily_phrase(cron)
74
+ return daily_phrase if daily_phrase
75
+
76
+ translate_phrase('phrases.cron_expression', expression: canonical)
77
+ end
78
+ private_class_method :humanize_cron
79
+
80
+ def every_minute_interval_phrase(cron)
81
+ return nil if cron.hours || cron.monthdays || cron.months || cron.weekdays
82
+
83
+ minutes = cron.minutes
84
+ return nil unless minutes.is_a?(Array) && minutes.length > 1
85
+
86
+ interval = derive_interval(minutes)
87
+ return nil unless interval
88
+
89
+ unit = interval_unit(interval, singular: 'minute', plural: 'minutes')
90
+ translate_phrase('phrases.every_interval', count: interval, unit: unit)
91
+ end
92
+ private_class_method :every_minute_interval_phrase
93
+
94
+ def derive_interval(minutes)
95
+ return nil unless minutes.first.zero?
96
+
97
+ diffs = minutes.each_cons(2).map { |left, right| right - left }.uniq
98
+ return nil unless diffs.length == 1
99
+
100
+ interval = diffs.first
101
+ return nil unless interval.positive?
102
+ return nil unless minutes.last == 60 - interval
103
+
104
+ interval
105
+ end
106
+ private_class_method :derive_interval
107
+
108
+ def at_time_weekday_phrase(cron)
109
+ return nil unless single_time?(cron)
110
+
111
+ weekdays = cron.weekdays
112
+ return nil if cron.monthdays || cron.months || !weekdays
113
+
114
+ weekday = extract_weekday(weekdays)
115
+ return nil unless weekday
116
+
117
+ time = format_time(cron.hours.first, cron.minutes.first)
118
+ "#{translate_phrase('phrases.at_time', time: time)} #{translate_phrase('every')} #{weekday_name(weekday)}"
119
+ end
120
+ private_class_method :at_time_weekday_phrase
121
+
122
+ def at_time_daily_phrase(cron)
123
+ return nil unless single_time?(cron)
124
+ return nil if cron.monthdays || cron.months || cron.weekdays
125
+
126
+ time = format_time(cron.hours.first, cron.minutes.first)
127
+ "#{translate_phrase('phrases.at_time', time: time)} #{translate_phrase('every')} #{translate_phrase('time.day')}"
128
+ end
129
+ private_class_method :at_time_daily_phrase
130
+
131
+ def single_time?(cron)
132
+ minutes = cron.minutes
133
+ hours = cron.hours
134
+
135
+ minutes.is_a?(Array) && minutes.size == 1 &&
136
+ hours.is_a?(Array) && hours.size == 1
137
+ end
138
+ private_class_method :single_time?
139
+
140
+ def extract_weekday(weekdays)
141
+ return nil unless weekdays.is_a?(Array) && weekdays.size == 1
142
+
143
+ token = weekdays.first
144
+ return token if token.is_a?(Integer)
145
+
146
+ if token.is_a?(Array) && token.size == 1
147
+ candidate = token.first
148
+ return candidate if candidate.is_a?(Integer)
149
+ end
150
+
151
+ nil
152
+ end
153
+ private_class_method :extract_weekday
154
+
155
+ def weekday_name(day)
156
+ normalized_day = day == 7 ? 0 : day
157
+ translate_phrase("weekdays.#{normalized_day}")
158
+ end
159
+ private_class_method :weekday_name
160
+
161
+ def format_time(hour, minute)
162
+ format('%<hour>02d:%<minute>02d', hour: hour, minute: minute)
163
+ end
164
+ private_class_method :format_time
165
+
166
+ def interval_unit(count, singular:, plural:)
167
+ key = count == 1 ? singular : plural
168
+ translate_phrase("time.#{key}")
169
+ end
170
+ private_class_method :interval_unit
171
+
172
+ def macro?(expression)
173
+ expression.start_with?('@')
174
+ end
175
+ private_class_method :macro?
176
+
177
+ def translate_phrase(key, **)
178
+ I18n.t("kaal.#{key}", **)
179
+ end
180
+ private_class_method :translate_phrase
181
+ end
182
+ end