better_model 1.3.0 → 2.0.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,1034 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Schedulable
5
+ # OccurrenceCalculator - Calcola occorrenze basate su regole di ricorrenza
6
+ #
7
+ # Questo è il cuore del sistema Schedulable. Prende una configurazione di schedule
8
+ # e calcola quando avvengono le occorrenze, rispettando eccezioni e vincoli.
9
+ #
10
+ class OccurrenceCalculator
11
+ attr_reader :config, :timezone
12
+
13
+ def initialize(config, timezone = "UTC")
14
+ # Convert string keys to symbols for easier access
15
+ @config = config.is_a?(Hash) ? config.with_indifferent_access : config
16
+ @timezone = ActiveSupport::TimeZone[timezone] || ActiveSupport::TimeZone["UTC"]
17
+ end
18
+
19
+ # Calcola la prossima occorrenza dopo un momento specifico
20
+ #
21
+ # @param after [Time] Momento di riferimento
22
+ # @return [Time, nil]
23
+ def next_occurrence(after: Time.current)
24
+ after = after.in_time_zone(@timezone)
25
+
26
+ # Controlla se siamo oltre ending_at
27
+ return nil if past_ending_date?(after)
28
+
29
+ # Genera candidati e trova il primo valido
30
+ candidates = generate_candidates(after, direction: :forward, limit: 1000)
31
+ candidates.find { |candidate| valid_occurrence?(candidate) && candidate > after }
32
+ end
33
+
34
+ # Calcola l'occorrenza precedente prima di un momento specifico
35
+ #
36
+ # @param before [Time] Momento di riferimento
37
+ # @return [Time, nil]
38
+ def previous_occurrence(before: Time.current)
39
+ before = before.in_time_zone(@timezone)
40
+
41
+ # Controlla se siamo prima di starting_from
42
+ return nil if before_starting_date?(before)
43
+
44
+ # Genera candidati e trova il primo valido
45
+ candidates = generate_candidates(before, direction: :backward, limit: 1000)
46
+ candidates.find { |candidate| valid_occurrence?(candidate) && candidate < before }
47
+ end
48
+
49
+ # Genera array di occorrenze in un range
50
+ #
51
+ # @param start_time [Time] Inizio range
52
+ # @param end_time [Time] Fine range
53
+ # @param limit [Integer] Limite occorrenze
54
+ # @return [Array<Time>]
55
+ def occurrences_between(start_time, end_time, limit: 100)
56
+ return [] if start_time.nil? || end_time.nil?
57
+
58
+ start_time = start_time.in_time_zone(@timezone)
59
+ end_time = end_time.in_time_zone(@timezone)
60
+
61
+ occurrences = []
62
+ current = start_time
63
+
64
+ while occurrences.size < limit && current <= end_time
65
+ next_occ = next_occurrence(after: current)
66
+ break unless next_occ
67
+ break if next_occ > end_time
68
+
69
+ occurrences << next_occ
70
+ current = next_occ + 1.second
71
+ end
72
+
73
+ occurrences
74
+ end
75
+
76
+ # Verifica se occorre in un momento specifico
77
+ #
78
+ # @param time [Time] Momento da verificare
79
+ # @return [Boolean]
80
+ def occurs_at?(time)
81
+ time = time.in_time_zone(@timezone)
82
+
83
+ # Se c'è duration, verifica se siamo dentro la durata
84
+ if config[:duration]
85
+ duration_seconds = config[:duration]
86
+ occurrence_start = find_occurrence_start_for(time)
87
+ return false unless occurrence_start
88
+
89
+ time >= occurrence_start && time < (occurrence_start + duration_seconds.seconds)
90
+ else
91
+ # Altrimenti deve matchare esattamente (o entro 1 minuto per tolleranza)
92
+ next_occ = next_occurrence(after: time - 1.minute)
93
+ return false unless next_occ
94
+
95
+ (time - next_occ).abs < 60 # Tolleranza 1 minuto
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # Genera candidati per occorrenze
102
+ def generate_candidates(from_time, direction:, limit:)
103
+ frequency = config[:frequency]
104
+
105
+ base_candidates = case frequency
106
+ when "daily"
107
+ generate_daily_candidates(from_time, direction, limit)
108
+ when "every_n_days"
109
+ generate_every_n_days_candidates(from_time, direction, limit)
110
+ when "weekly"
111
+ generate_weekly_candidates(from_time, direction, limit)
112
+ when "biweekly"
113
+ generate_biweekly_candidates(from_time, direction, limit)
114
+ when "every_n_weeks"
115
+ generate_every_n_weeks_candidates(from_time, direction, limit)
116
+ when "monthly"
117
+ generate_monthly_candidates(from_time, direction, limit)
118
+ when "every_n_months"
119
+ generate_every_n_months_candidates(from_time, direction, limit)
120
+ when "yearly"
121
+ generate_yearly_candidates(from_time, direction, limit)
122
+ when "every_n_years"
123
+ generate_every_n_years_candidates(from_time, direction, limit)
124
+ when "every_n_hours"
125
+ generate_hourly_candidates(from_time, direction, limit)
126
+ when "every_n_minutes"
127
+ generate_minutely_candidates(from_time, direction, limit)
128
+ else
129
+ []
130
+ end
131
+
132
+ # Expand candidates with advance_by reminders if specified
133
+ expand_with_advance_reminders(base_candidates)
134
+ end
135
+
136
+ # Expands base occurrences with advance reminders
137
+ def expand_with_advance_reminders(base_candidates)
138
+ advance_by = config[:advance_by]
139
+ return base_candidates unless advance_by.present?
140
+
141
+ advance_offsets = Array(advance_by)
142
+ all_candidates = []
143
+
144
+ base_candidates.each do |base_time|
145
+ # Add all advance reminder times
146
+ advance_offsets.each do |offset_seconds|
147
+ reminder_time = base_time + offset_seconds.seconds
148
+ all_candidates << reminder_time
149
+ end
150
+ end
151
+
152
+ all_candidates.sort
153
+ end
154
+
155
+ # ========================================
156
+ # DAILY
157
+ # ========================================
158
+
159
+ def generate_daily_candidates(from_time, direction, limit)
160
+ times = config[:time] || ["00:00"]
161
+ candidates = []
162
+
163
+ if direction == :forward
164
+ current_date = from_time.to_date
165
+ days_checked = 0
166
+
167
+ while candidates.size < limit && days_checked < limit
168
+ times.each do |time_str|
169
+ candidate = parse_time_on_date(current_date, time_str)
170
+ candidates << candidate if candidate >= from_time
171
+ end
172
+
173
+ current_date += 1.day
174
+ days_checked += 1
175
+ end
176
+ else
177
+ current_date = from_time.to_date
178
+ days_checked = 0
179
+
180
+ while candidates.size < limit && days_checked < limit
181
+ times.reverse.each do |time_str|
182
+ candidate = parse_time_on_date(current_date, time_str)
183
+ candidates << candidate if candidate <= from_time
184
+ end
185
+
186
+ current_date -= 1.day
187
+ days_checked += 1
188
+ end
189
+ end
190
+
191
+ candidates
192
+ end
193
+
194
+ def generate_every_n_days_candidates(from_time, direction, limit)
195
+ interval = config[:interval] || 1
196
+ times = config[:time] || ["00:00"]
197
+ starting_from = config[:starting_from] ? (config[:starting_from].is_a?(String) ? Date.parse(config[:starting_from]) : config[:starting_from].to_date) : from_time.to_date
198
+
199
+ candidates = []
200
+
201
+ if direction == :forward
202
+ current_date = starting_from
203
+ # Avanza fino alla prima data >= from_time
204
+ while current_date < from_time.to_date
205
+ current_date += interval.days
206
+ end
207
+
208
+ while candidates.size < limit
209
+ times.each do |time_str|
210
+ candidate = parse_time_on_date(current_date, time_str)
211
+ candidates << candidate if candidate >= from_time
212
+ end
213
+
214
+ current_date += interval.days
215
+ end
216
+ else
217
+ # Backward - implementazione semplificata
218
+ current_date = from_time.to_date
219
+
220
+ while candidates.size < limit
221
+ times.reverse.each do |time_str|
222
+ candidate = parse_time_on_date(current_date, time_str)
223
+ candidates << candidate if candidate <= from_time
224
+ end
225
+
226
+ current_date -= interval.days
227
+ end
228
+ end
229
+
230
+ candidates
231
+ end
232
+
233
+ # ========================================
234
+ # WEEKLY
235
+ # ========================================
236
+
237
+ def generate_weekly_candidates(from_time, direction, limit)
238
+ weekdays = config[:weekday] || []
239
+ times = config[:time] || ["00:00"]
240
+
241
+ return [] if weekdays.empty?
242
+
243
+ candidates = []
244
+
245
+ if direction == :forward
246
+ current_date = from_time.to_date
247
+ days_checked = 0
248
+
249
+ while candidates.size < limit && days_checked < 365
250
+ day_name = current_date.strftime("%A").downcase
251
+ if weekdays.include?(day_name)
252
+ times.each do |time_str|
253
+ candidate = parse_time_on_date(current_date, time_str)
254
+ candidates << candidate if candidate >= from_time
255
+ end
256
+ end
257
+
258
+ current_date += 1.day
259
+ days_checked += 1
260
+ end
261
+ else
262
+ current_date = from_time.to_date
263
+ days_checked = 0
264
+
265
+ while candidates.size < limit && days_checked < 365
266
+ day_name = current_date.strftime("%A").downcase
267
+ if weekdays.include?(day_name)
268
+ times.reverse.each do |time_str|
269
+ candidate = parse_time_on_date(current_date, time_str)
270
+ candidates << candidate if candidate <= from_time
271
+ end
272
+ end
273
+
274
+ current_date -= 1.day
275
+ days_checked += 1
276
+ end
277
+ end
278
+
279
+ candidates
280
+ end
281
+
282
+ def generate_biweekly_candidates(from_time, direction, limit)
283
+ # Biweekly è every_n_weeks con interval 2
284
+ old_interval = config[:interval]
285
+ config[:interval] = 2
286
+ result = generate_every_n_weeks_candidates(from_time, direction, limit)
287
+ config[:interval] = old_interval
288
+ result
289
+ end
290
+
291
+ def generate_every_n_weeks_candidates(from_time, direction, limit)
292
+ interval = config[:interval] || 1
293
+ weekdays = config[:weekday] || []
294
+ times = config[:time] || ["00:00"]
295
+ starting_from = config[:starting_from] ? (config[:starting_from].is_a?(String) ? Date.parse(config[:starting_from]) : config[:starting_from].to_date) : from_time.to_date
296
+
297
+ return [] if weekdays.empty?
298
+
299
+ candidates = []
300
+
301
+ if direction == :forward
302
+ # Use the later of starting_from or from_time as the search start
303
+ search_from = [starting_from, from_time.to_date].max
304
+
305
+ # Trova la prima settimana >= search_from basata sul pattern di starting_from
306
+ base_week_start = starting_from.beginning_of_week
307
+ current_week_start = base_week_start
308
+
309
+ while current_week_start < search_from.beginning_of_week
310
+ current_week_start += interval.weeks
311
+ end
312
+
313
+ weeks_checked = 0
314
+ while candidates.size < limit && weeks_checked < 100
315
+ # Genera occorrenze per questa settimana
316
+ (0..6).each do |day_offset|
317
+ current_date = current_week_start + day_offset.days
318
+ day_name = current_date.strftime("%A").downcase
319
+
320
+ if weekdays.include?(day_name)
321
+ times.each do |time_str|
322
+ candidate = parse_time_on_date(current_date, time_str)
323
+ # Filter by both from_time AND starting_from
324
+ candidates << candidate if candidate >= from_time && candidate.to_date >= starting_from
325
+ end
326
+ end
327
+ end
328
+
329
+ current_week_start += interval.weeks
330
+ weeks_checked += 1
331
+ end
332
+ else
333
+ # Backward
334
+ current_week_start = from_time.to_date.beginning_of_week
335
+ weeks_checked = 0
336
+
337
+ while candidates.size < limit && weeks_checked < 100
338
+ (0..6).reverse_each do |day_offset|
339
+ current_date = current_week_start + day_offset.days
340
+ day_name = current_date.strftime("%A").downcase
341
+
342
+ if weekdays.include?(day_name)
343
+ times.reverse.each do |time_str|
344
+ candidate = parse_time_on_date(current_date, time_str)
345
+ candidates << candidate if candidate <= from_time
346
+ end
347
+ end
348
+ end
349
+
350
+ current_week_start -= interval.weeks
351
+ weeks_checked += 1
352
+ end
353
+ end
354
+
355
+ candidates
356
+ end
357
+
358
+ # ========================================
359
+ # MONTHLY
360
+ # ========================================
361
+
362
+ def generate_monthly_candidates(from_time, direction, limit)
363
+ # Due modalità: day of month o weekday occurrence
364
+ if config[:day]
365
+ generate_monthly_by_day(from_time, direction, limit)
366
+ elsif config[:weekday] && config[:occurrence]
367
+ generate_monthly_by_weekday(from_time, direction, limit)
368
+ else
369
+ []
370
+ end
371
+ end
372
+
373
+ def generate_monthly_by_day(from_time, direction, limit)
374
+ days = config[:day]
375
+ times = config[:time] || ["00:00"]
376
+
377
+ candidates = []
378
+
379
+ if direction == :forward
380
+ current_date = from_time.to_date.beginning_of_month
381
+ months_checked = 0
382
+
383
+ while candidates.size < limit && months_checked < 120
384
+ days.each do |day|
385
+ actual_day = day_of_month(current_date.year, current_date.month, day)
386
+ next unless actual_day
387
+
388
+ date = Date.new(current_date.year, current_date.month, actual_day)
389
+
390
+ times.each do |time_str|
391
+ candidate = parse_time_on_date(date, time_str)
392
+ candidates << candidate if candidate >= from_time
393
+ end
394
+ end
395
+
396
+ current_date += 1.month
397
+ months_checked += 1
398
+ end
399
+ else
400
+ current_date = from_time.to_date.beginning_of_month
401
+ months_checked = 0
402
+
403
+ while candidates.size < limit && months_checked < 120
404
+ days.reverse.each do |day|
405
+ actual_day = day_of_month(current_date.year, current_date.month, day)
406
+ next unless actual_day
407
+
408
+ date = Date.new(current_date.year, current_date.month, actual_day)
409
+
410
+ times.reverse.each do |time_str|
411
+ candidate = parse_time_on_date(date, time_str)
412
+ candidates << candidate if candidate <= from_time
413
+ end
414
+ end
415
+
416
+ current_date -= 1.month
417
+ months_checked += 1
418
+ end
419
+ end
420
+
421
+ candidates
422
+ end
423
+
424
+ def generate_monthly_by_weekday(from_time, direction, limit)
425
+ weekdays = config[:weekday]
426
+ occurrence = config[:occurrence]
427
+ times = config[:time] || ["00:00"]
428
+
429
+ candidates = []
430
+
431
+ if direction == :forward
432
+ current_date = from_time.to_date.beginning_of_month
433
+ months_checked = 0
434
+
435
+ while candidates.size < limit && months_checked < 120
436
+ weekdays.each do |weekday_name|
437
+ date = nth_weekday_of_month(current_date.year, current_date.month, weekday_name, occurrence)
438
+ next unless date
439
+
440
+ times.each do |time_str|
441
+ candidate = parse_time_on_date(date, time_str)
442
+ candidates << candidate if candidate >= from_time
443
+ end
444
+ end
445
+
446
+ current_date += 1.month
447
+ months_checked += 1
448
+ end
449
+ else
450
+ current_date = from_time.to_date.beginning_of_month
451
+ months_checked = 0
452
+
453
+ while candidates.size < limit && months_checked < 120
454
+ weekdays.reverse.each do |weekday_name|
455
+ date = nth_weekday_of_month(current_date.year, current_date.month, weekday_name, occurrence)
456
+ next unless date
457
+
458
+ times.reverse.each do |time_str|
459
+ candidate = parse_time_on_date(date, time_str)
460
+ candidates << candidate if candidate <= from_time
461
+ end
462
+ end
463
+
464
+ current_date -= 1.month
465
+ months_checked += 1
466
+ end
467
+ end
468
+
469
+ candidates
470
+ end
471
+
472
+ def generate_every_n_months_candidates(from_time, direction, limit)
473
+ interval = config[:interval] || 1
474
+
475
+ # Determine the starting month for the pattern
476
+ base_date = if config[:starting_from]
477
+ config[:starting_from].is_a?(String) ? Date.parse(config[:starting_from]) : config[:starting_from].to_date
478
+ else
479
+ # Use from_time's month as the base for additive pattern
480
+ from_time.to_date
481
+ end
482
+
483
+ candidates = []
484
+
485
+ # Salva interval originale e modifica temporaneamente per usare monthly logic
486
+ original_freq = config[:frequency]
487
+ config[:frequency] = "monthly"
488
+
489
+ if direction == :forward
490
+ # For every_n_months, skip current month and jump by interval
491
+ # Semantic: "every N months" means starting from NEXT interval period
492
+ # Example: From Nov 15, interval=2 → Jan (not Nov)
493
+ current_date = from_time.beginning_of_month + interval.months
494
+ months_checked = 0
495
+
496
+ while candidates.size < limit && months_checked < 100
497
+ # Generate all occurrences for this month
498
+ month_start = @timezone.parse("#{current_date} 00:00")
499
+ month_end = month_start.end_of_month
500
+
501
+ # Get all candidates in this month
502
+ month_candidates = generate_monthly_candidates(month_start, :forward, 100)
503
+ .select { |c| c >= month_start && c <= month_end }
504
+
505
+ # Add all candidates from this month
506
+ month_candidates.each do |candidate|
507
+ candidates << candidate
508
+ break if candidates.size >= limit
509
+ end
510
+
511
+ # Move to next valid month (add interval months)
512
+ current_date = current_date + interval.months
513
+ months_checked += 1
514
+ end
515
+ else
516
+ # Backward direction: subtract interval months iteratively
517
+ current_date = base_date.beginning_of_month
518
+ months_checked = 0
519
+
520
+ while candidates.size < limit && months_checked < 100
521
+ # Generate all occurrences for this month
522
+ month_start = @timezone.parse("#{current_date} 00:00")
523
+ month_end = month_start.end_of_month
524
+
525
+ # Get all candidates in this month
526
+ month_candidates = generate_monthly_candidates(month_start, :forward, 100)
527
+ .select { |c| c >= month_start && c <= month_end }
528
+ .reverse
529
+
530
+ # Add candidates that are before or equal to from_time
531
+ month_candidates.each do |candidate|
532
+ candidates << candidate if candidate <= from_time
533
+ end
534
+
535
+ # Move to previous valid month (subtract interval months)
536
+ current_date = current_date - interval.months
537
+ months_checked += 1
538
+ end
539
+ end
540
+
541
+ config[:frequency] = original_freq
542
+ candidates.first(limit)
543
+ end
544
+
545
+ # ========================================
546
+ # YEARLY
547
+ # ========================================
548
+
549
+ def generate_yearly_candidates(from_time, direction, limit)
550
+ dates = config[:date] || []
551
+ times = config[:time] || ["00:00"]
552
+
553
+ candidates = []
554
+
555
+ if direction == :forward
556
+ current_year = from_time.year
557
+ years_checked = 0
558
+
559
+ while candidates.size < limit && years_checked < 50
560
+ dates.each do |date_str|
561
+ month, day = date_str.split("-").map(&:to_i)
562
+ date = Date.new(current_year, month, day) rescue nil
563
+ next unless date
564
+
565
+ times.each do |time_str|
566
+ candidate = parse_time_on_date(date, time_str)
567
+ candidates << candidate if candidate > from_time
568
+ end
569
+ end
570
+
571
+ current_year += 1
572
+ years_checked += 1
573
+ end
574
+ else
575
+ current_year = from_time.year
576
+ years_checked = 0
577
+
578
+ while candidates.size < limit && years_checked < 50
579
+ dates.reverse.each do |date_str|
580
+ month, day = date_str.split("-").map(&:to_i)
581
+ date = Date.new(current_year, month, day) rescue nil
582
+ next unless date
583
+
584
+ times.reverse.each do |time_str|
585
+ candidate = parse_time_on_date(date, time_str)
586
+ candidates << candidate if candidate <= from_time
587
+ end
588
+ end
589
+
590
+ current_year -= 1
591
+ years_checked += 1
592
+ end
593
+ end
594
+
595
+ candidates
596
+ end
597
+
598
+ def generate_every_n_years_candidates(from_time, direction, limit)
599
+ interval = config[:interval] || 1
600
+ dates = config[:date] || []
601
+ times = config[:time] || ["00:00"]
602
+
603
+ candidates = []
604
+
605
+ if direction == :forward
606
+ # Determine base year - the anchor year for the pattern
607
+ # This must be consistent across all calls, not dependent on from_time
608
+ base_year = if config[:starting_from]
609
+ starting_date = config[:starting_from].is_a?(String) ? Date.parse(config[:starting_from]) : config[:starting_from].to_date
610
+ starting_date.year
611
+ else
612
+ # Use year 2000 as a universal base for patterns without starting_from
613
+ # This ensures consistent interval calculation across all calls
614
+ 2000
615
+ end
616
+
617
+ # Calculate first valid year >= from_time using the interval pattern
618
+ # Valid years are: base_year, base_year + interval, base_year + 2*interval, ...
619
+ # Find the smallest valid year >= from_time.year
620
+ years_diff = from_time.year - base_year
621
+ if years_diff >= 0
622
+ # from_time is after base_year, calculate how many intervals to skip
623
+ intervals_to_skip = (years_diff.to_f / interval).ceil
624
+ current_year = base_year + (intervals_to_skip * interval)
625
+ else
626
+ # from_time is before base_year, start from base_year
627
+ current_year = base_year
628
+ end
629
+
630
+ years_generated = 0
631
+ while candidates.size < limit && years_generated < 50
632
+ dates.each do |date_str|
633
+ month, day = date_str.split("-").map(&:to_i)
634
+ date = Date.new(current_year, month, day) rescue nil
635
+ next unless date
636
+
637
+ times.each do |time_str|
638
+ candidate = parse_time_on_date(date, time_str)
639
+ candidates << candidate if candidate > from_time
640
+ end
641
+ end
642
+
643
+ current_year += interval
644
+ years_generated += 1
645
+ end
646
+ else
647
+ current_year = from_time.year
648
+ years_generated = 0
649
+
650
+ while candidates.size < limit && years_generated < 50
651
+ dates.reverse.each do |date_str|
652
+ month, day = date_str.split("-").map(&:to_i)
653
+ date = Date.new(current_year, month, day)
654
+
655
+ times.reverse.each do |time_str|
656
+ candidate = parse_time_on_date(date, time_str)
657
+ candidates << candidate if candidate <= from_time
658
+ end
659
+ end
660
+
661
+ current_year -= interval
662
+ years_generated += 1
663
+ end
664
+ end
665
+
666
+ candidates
667
+ end
668
+
669
+ # ========================================
670
+ # HOURLY & MINUTELY
671
+ # ========================================
672
+
673
+ def generate_hourly_candidates(from_time, direction, limit)
674
+ interval = config[:interval] || 1
675
+ at_minutes = config[:at_minutes] || [0]
676
+
677
+ candidates = []
678
+
679
+ if direction == :forward
680
+ # Find next aligned hour slot (aligned to midnight)
681
+ # For interval=4: slots are at 00:00, 04:00, 08:00, 12:00, 16:00, 20:00
682
+ # But first check if there are remaining at_minutes in current aligned slot
683
+ current_hour = from_time.hour
684
+
685
+ # Find the current aligned hour slot
686
+ current_aligned_hour = (current_hour / interval) * interval
687
+
688
+ # Check if there are remaining minutes in the current aligned slot
689
+ at_minutes.each do |minute|
690
+ candidate = from_time.change(hour: current_aligned_hour, min: minute, sec: 0)
691
+ if candidate > from_time && within_time_range?(candidate)
692
+ candidates << candidate
693
+ break if candidates.size >= limit
694
+ end
695
+ end
696
+
697
+ # If we filled the limit with current slot, we're done
698
+ return candidates if candidates.size >= limit
699
+
700
+ # Calculate next aligned hour after current slot
701
+ next_aligned_hour = current_aligned_hour + interval
702
+
703
+ slots_checked = 0
704
+
705
+ while candidates.size < limit && slots_checked < 10000
706
+ # Handle hour wrapping to next days
707
+ effective_hour = next_aligned_hour + (slots_checked * interval)
708
+ days_to_add = effective_hour / 24
709
+ hour_in_day = effective_hour % 24
710
+
711
+ candidate_date = from_time.to_date + days_to_add.days
712
+
713
+ # Generate candidates for each at_minute in this hour slot
714
+ at_minutes.each do |minute|
715
+ candidate = candidate_date.in_time_zone(timezone).change(hour: hour_in_day, min: minute, sec: 0)
716
+
717
+ # Only add if it's after from_time and within time range
718
+ if candidate > from_time && within_time_range?(candidate)
719
+ candidates << candidate
720
+ end
721
+
722
+ break if candidates.size >= limit
723
+ end
724
+
725
+ slots_checked += 1
726
+ end
727
+ else
728
+ # Backward direction
729
+ current_time = from_time
730
+ hours_generated = 0
731
+
732
+ while candidates.size < limit && hours_generated < 1000
733
+ at_minutes.reverse.each do |minute|
734
+ candidate = current_time.change(min: minute, sec: 0)
735
+
736
+ if within_time_range?(candidate) && candidate <= from_time
737
+ candidates << candidate
738
+ end
739
+ end
740
+
741
+ current_time -= interval.hours
742
+ hours_generated += 1
743
+ end
744
+ end
745
+
746
+ candidates
747
+ end
748
+
749
+ def generate_minutely_candidates(from_time, direction, limit)
750
+ interval = config[:interval] || 1
751
+ time_range = config[:between]
752
+
753
+ candidates = []
754
+
755
+ if direction == :forward
756
+ current_date = from_time.to_date
757
+ days_checked = 0
758
+
759
+ while candidates.size < limit && days_checked < 100
760
+ # Determine the start time for this day
761
+ if time_range.is_a?(Array)
762
+ # Start from beginning of time range
763
+ start_hour_str = time_range[0]
764
+ day_start = @timezone.parse("#{current_date} #{start_hour_str}")
765
+ else
766
+ # Start from midnight
767
+ day_start = current_date.to_time.in_time_zone(@timezone)
768
+ end
769
+
770
+ # Generate minutely slots for this day at the interval
771
+ current_minute = 0
772
+ while current_minute < 1440 # 24 hours = 1440 minutes
773
+ slot_time = day_start + current_minute.minutes
774
+ candidate = slot_time.change(sec: 0)
775
+
776
+ # Check if it's valid (after from_time and within time range)
777
+ if candidate > from_time && within_time_range?(candidate)
778
+ candidates << candidate
779
+ break if candidates.size >= limit
780
+ end
781
+
782
+ break if candidates.size >= limit
783
+ current_minute += interval
784
+ end
785
+
786
+ current_date += 1.day
787
+ days_checked += 1
788
+ end
789
+ else
790
+ # Backward direction
791
+ current_time = from_time.change(sec: 0)
792
+ minutes_generated = 0
793
+
794
+ while candidates.size < limit && minutes_generated < 10000
795
+ if within_time_range?(current_time) && current_time <= from_time
796
+ candidates << current_time
797
+ end
798
+
799
+ current_time -= interval.minutes
800
+ minutes_generated += 1
801
+ end
802
+ end
803
+
804
+ candidates
805
+ end
806
+
807
+ # ========================================
808
+ # HELPER METHODS
809
+ # ========================================
810
+
811
+ def valid_occurrence?(time)
812
+ return false if before_starting_date?(time)
813
+ return false if past_ending_date?(time)
814
+ return false if within_time_range_defined? && !within_time_range?(time)
815
+ return false if matches_exception?(time)
816
+
817
+ true
818
+ end
819
+
820
+ def before_starting_date?(time)
821
+ return false unless config[:starting_from].present?
822
+
823
+ starting = case config[:starting_from]
824
+ when String
825
+ Date.parse(config[:starting_from])
826
+ when Date
827
+ config[:starting_from]
828
+ when Time, ActiveSupport::TimeWithZone
829
+ config[:starting_from].to_date
830
+ else
831
+ return false # Unknown type, assume no constraint
832
+ end
833
+ time.to_date < starting
834
+ rescue ArgumentError, TypeError => _e
835
+ # If we can't parse the starting_from, assume no constraint
836
+ Rails.logger.warn("Invalid starting_from value: #{config[:starting_from].inspect}") if defined?(Rails)
837
+ false
838
+ end
839
+
840
+ def past_ending_date?(time)
841
+ return false unless config[:ending_at].present?
842
+
843
+ ending = case config[:ending_at]
844
+ when String
845
+ Date.parse(config[:ending_at])
846
+ when Date
847
+ config[:ending_at]
848
+ when Time, ActiveSupport::TimeWithZone
849
+ config[:ending_at].to_date
850
+ else
851
+ return false # Unknown type, assume no constraint
852
+ end
853
+ time.to_date > ending
854
+ rescue ArgumentError, TypeError => _e
855
+ # If we can't parse the ending_at, assume no constraint
856
+ Rails.logger.warn("Invalid ending_at value: #{config[:ending_at].inspect}") if defined?(Rails)
857
+ false
858
+ end
859
+
860
+ def within_time_range_defined?
861
+ config[:between].present?
862
+ end
863
+
864
+ def within_time_range?(time)
865
+ return true unless config[:between]
866
+
867
+ range = config[:between]
868
+
869
+ # Se è un hash, è un date range
870
+ if range.is_a?(Hash)
871
+ return true unless range[:start].present? && range[:end].present?
872
+
873
+ start_date = case range[:start]
874
+ when String
875
+ Date.parse(range[:start])
876
+ when Date
877
+ range[:start]
878
+ when Time, ActiveSupport::TimeWithZone
879
+ range[:start].to_date
880
+ else
881
+ return true
882
+ end
883
+ end_date = case range[:end]
884
+ when String
885
+ Date.parse(range[:end])
886
+ when Date
887
+ range[:end]
888
+ when Time, ActiveSupport::TimeWithZone
889
+ range[:end].to_date
890
+ else
891
+ return true
892
+ end
893
+ return time.to_date >= start_date && time.to_date <= end_date
894
+ end
895
+
896
+ # Altrimenti è un time range (es: ['08:00', '18:00'])
897
+ # End time is EXCLUSIVE (between means >= start and < end)
898
+ if range.is_a?(Array) && range.size == 2
899
+ start_time_str, end_time_str = range
900
+ start_hour, start_min = start_time_str.split(":").map(&:to_i)
901
+ end_hour, end_min = end_time_str.split(":").map(&:to_i)
902
+
903
+ time_in_seconds = time.hour * 3600 + time.min * 60
904
+ start_in_seconds = start_hour * 3600 + start_min * 60
905
+ end_in_seconds = end_hour * 3600 + end_min * 60
906
+
907
+ return time_in_seconds >= start_in_seconds && time_in_seconds < end_in_seconds
908
+ end
909
+
910
+ true
911
+ end
912
+
913
+ def matches_exception?(time)
914
+ exceptions = config[:exceptions]
915
+ return false unless exceptions
916
+
917
+ # Exception: weekdays
918
+ if exceptions[:weekdays]
919
+ weekday_name = time.strftime("%A").downcase
920
+ return true if exceptions[:weekdays].include?(weekday_name)
921
+ end
922
+
923
+ # Exception: specific dates
924
+ if exceptions[:dates]
925
+ # Support both full date format (YYYY-MM-DD) and short format (MM-DD)
926
+ full_date_str = time.strftime("%Y-%m-%d")
927
+ short_date_str = time.strftime("%m-%d")
928
+ month_day_str = time.strftime("%-m-%-d") # Without leading zeros
929
+
930
+ return true if exceptions[:dates].include?(full_date_str)
931
+ return true if exceptions[:dates].include?(short_date_str)
932
+ return true if exceptions[:dates].include?(month_day_str)
933
+ end
934
+
935
+ # Exception: months
936
+ if exceptions[:months]
937
+ return true if exceptions[:months].include?(time.month)
938
+ end
939
+
940
+ # Exception: patterns (first friday, etc.)
941
+ if exceptions[:patterns]
942
+ exceptions[:patterns].each do |pattern|
943
+ return true if matches_exception_pattern?(time, pattern)
944
+ end
945
+ end
946
+
947
+ false
948
+ end
949
+
950
+ def matches_exception_pattern?(time, pattern)
951
+ if pattern[:weekday] && pattern[:occurrence]
952
+ weekday_name = pattern[:weekday].to_s
953
+ occurrence = pattern[:occurrence]
954
+
955
+ # Verifica se time è l'N-esimo weekday del mese
956
+ expected_date = nth_weekday_of_month(time.year, time.month, weekday_name, occurrence)
957
+ return time.to_date == expected_date
958
+ end
959
+
960
+ false
961
+ end
962
+
963
+ def parse_time_on_date(date, time_str)
964
+ hour, minute = time_str.split(":").map(&:to_i)
965
+ @timezone.local(date.year, date.month, date.day, hour, minute, 0)
966
+ end
967
+
968
+ def day_of_month(year, month, day_spec)
969
+ if day_spec > 0
970
+ # Positivo: giorno dal primo (1, 2, 3, ...)
971
+ last_day = Date.new(year, month, -1).day
972
+ return nil if day_spec > last_day
973
+ day_spec
974
+ else
975
+ # Negativo: giorno dalla fine (-1 = ultimo, -2 = penultimo, ...)
976
+ last_day = Date.new(year, month, -1).day
977
+ actual_day = last_day + day_spec + 1
978
+ return nil if actual_day < 1
979
+ actual_day
980
+ end
981
+ end
982
+
983
+ def nth_weekday_of_month(year, month, weekday_name, occurrence)
984
+ weekday_num = Date::DAYNAMES.map(&:downcase).index(weekday_name)
985
+ return nil unless weekday_num
986
+
987
+ if occurrence > 0
988
+ # Primo, secondo, terzo, ...
989
+ first_day = Date.new(year, month, 1)
990
+ first_weekday = first_day
991
+
992
+ # Trova il primo weekday_name del mese
993
+ while first_weekday.wday != weekday_num
994
+ first_weekday += 1.day
995
+ end
996
+
997
+ # Aggiungi (occurrence - 1) settimane
998
+ result = first_weekday + (occurrence - 1).weeks
999
+
1000
+ # Verifica che sia ancora nello stesso mese
1001
+ return nil if result.month != month
1002
+
1003
+ result
1004
+ else
1005
+ # Ultimo, penultimo, ...
1006
+ last_day = Date.new(year, month, -1)
1007
+ last_weekday = last_day
1008
+
1009
+ # Trova l'ultimo weekday_name del mese
1010
+ while last_weekday.wday != weekday_num
1011
+ last_weekday -= 1.day
1012
+ end
1013
+
1014
+ # Sottrai settimane
1015
+ result = last_weekday + (occurrence + 1).weeks
1016
+
1017
+ # Verifica che sia ancora nello stesso mese
1018
+ return nil if result.month != month
1019
+
1020
+ result
1021
+ end
1022
+ end
1023
+
1024
+ def find_occurrence_start_for(time)
1025
+ # Trova l'occorrenza che inizia prima o uguale a time
1026
+ # Questo è usato per duration check
1027
+ prev = previous_occurrence(before: time + 1.second)
1028
+ return prev if prev && prev <= time
1029
+
1030
+ nil
1031
+ end
1032
+ end
1033
+ end
1034
+ end