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.
- checksums.yaml +4 -4
- data/README.md +155 -1
- data/lib/better_model/archivable.rb +3 -2
- data/lib/better_model/predicable.rb +73 -52
- data/lib/better_model/schedulable/occurrence_calculator.rb +1034 -0
- data/lib/better_model/schedulable/schedule_builder.rb +269 -0
- data/lib/better_model/schedulable.rb +356 -0
- data/lib/better_model/searchable.rb +4 -4
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model.rb +4 -0
- data/lib/generators/better_model/taggable/taggable_generator.rb +129 -0
- data/lib/generators/better_model/taggable/templates/README.tt +62 -0
- data/lib/generators/better_model/taggable/templates/migration.rb.tt +21 -0
- metadata +8 -2
|
@@ -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
|