hron 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +98 -0
- data/Rakefile +13 -0
- data/hron.gemspec +39 -0
- data/lib/hron/ast.rb +235 -0
- data/lib/hron/cron.rb +250 -0
- data/lib/hron/display.rb +166 -0
- data/lib/hron/error.rb +64 -0
- data/lib/hron/evaluator.rb +725 -0
- data/lib/hron/lexer.rb +253 -0
- data/lib/hron/parser.rb +617 -0
- data/lib/hron/schedule.rb +75 -0
- data/lib/hron/version.rb +5 -0
- data/lib/hron.rb +30 -0
- metadata +116 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "tzinfo"
|
|
6
|
+
require_relative "ast"
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Hron
|
|
10
|
+
EPOCH_DATE = Date.new(1970, 1, 1)
|
|
11
|
+
EPOCH_MONDAY = Date.new(1970, 1, 5)
|
|
12
|
+
|
|
13
|
+
# Timezone resolution
|
|
14
|
+
module TzResolver
|
|
15
|
+
def self.resolve(tz_name)
|
|
16
|
+
if tz_name && !tz_name.empty?
|
|
17
|
+
TZInfo::Timezone.get(tz_name)
|
|
18
|
+
else
|
|
19
|
+
# Default to UTC for deterministic, portable behavior
|
|
20
|
+
TZInfo::Timezone.get("UTC")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Evaluator helpers
|
|
26
|
+
module EvalHelpers
|
|
27
|
+
def self.at_time_on_date(d, tod, tz)
|
|
28
|
+
# Create local time representation (using UTC to avoid system TZ interference)
|
|
29
|
+
local_time = Time.utc(d.year, d.month, d.day, tod.hour, tod.minute, 0)
|
|
30
|
+
|
|
31
|
+
# Get periods for this local time
|
|
32
|
+
periods = tz.periods_for_local(local_time)
|
|
33
|
+
|
|
34
|
+
case periods.length
|
|
35
|
+
when 0
|
|
36
|
+
# Time doesn't exist (spring forward gap)
|
|
37
|
+
# Push the time forward past the gap (like "compatible" disambiguation)
|
|
38
|
+
# For example: 02:30 during DST spring forward -> 03:30
|
|
39
|
+
|
|
40
|
+
# Find the transition on this date
|
|
41
|
+
day_start = Time.utc(d.year, d.month, d.day, 0, 0, 0)
|
|
42
|
+
day_end = Time.utc(d.year, d.month, d.day, 23, 59, 59)
|
|
43
|
+
transitions = tz.transitions_up_to(day_end, day_start)
|
|
44
|
+
|
|
45
|
+
if transitions.any?
|
|
46
|
+
# Find the spring-forward transition (where offset increases / DST starts)
|
|
47
|
+
transition = transitions.find { |t| t.offset.dst? }
|
|
48
|
+
if transition
|
|
49
|
+
# The transition time is when the gap starts
|
|
50
|
+
# We need to push the requested time forward by the gap size
|
|
51
|
+
prev_offset = transition.previous_offset.utc_total_offset
|
|
52
|
+
new_offset = transition.offset.utc_total_offset
|
|
53
|
+
gap_seconds = new_offset - prev_offset # Positive for spring forward
|
|
54
|
+
|
|
55
|
+
# Push the local time forward by the gap amount
|
|
56
|
+
# E.g., 02:30 + 1 hour = 03:30 local
|
|
57
|
+
pushed_local = local_time + gap_seconds
|
|
58
|
+
|
|
59
|
+
# Convert the pushed local time to UTC using the new offset
|
|
60
|
+
# E.g., 03:30 local EDT (-4h) -> 07:30 UTC
|
|
61
|
+
return pushed_local - new_offset
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Fallback: try to construct the time and let TZInfo handle it
|
|
66
|
+
# This shouldn't normally be reached
|
|
67
|
+
begin
|
|
68
|
+
tz.local_to_utc(local_time)
|
|
69
|
+
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
when 1
|
|
73
|
+
# Unambiguous time - straightforward conversion
|
|
74
|
+
period = periods[0]
|
|
75
|
+
utc_offset = period.offset.utc_total_offset
|
|
76
|
+
local_time - utc_offset
|
|
77
|
+
when 2
|
|
78
|
+
# Ambiguous time (fall back) - use first occurrence (pre-transition)
|
|
79
|
+
# Period 0 is the earlier offset (e.g., EDT -04:00 before fall back)
|
|
80
|
+
period = periods[0]
|
|
81
|
+
utc_offset = period.offset.utc_total_offset
|
|
82
|
+
local_time - utc_offset
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.matches_day_filter(d, filter)
|
|
87
|
+
dow = d.cwday # Monday=1 ... Sunday=7
|
|
88
|
+
case filter
|
|
89
|
+
when DayFilterEvery
|
|
90
|
+
true
|
|
91
|
+
when DayFilterWeekday
|
|
92
|
+
dow.between?(1, 5)
|
|
93
|
+
when DayFilterWeekend
|
|
94
|
+
[6, 7].include?(dow)
|
|
95
|
+
when DayFilterDays
|
|
96
|
+
filter.days.any? { |wd| Weekday.number(wd) == dow }
|
|
97
|
+
else
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.last_day_of_month(year, month)
|
|
103
|
+
Date.new(year, month, -1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.last_weekday_of_month(year, month)
|
|
107
|
+
d = last_day_of_month(year, month)
|
|
108
|
+
d -= 1 while d.cwday >= 6
|
|
109
|
+
d
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.nth_weekday_of_month(year, month, weekday, n)
|
|
113
|
+
target_dow = Weekday.number(weekday)
|
|
114
|
+
d = Date.new(year, month, 1)
|
|
115
|
+
d += 1 while d.cwday != target_dow
|
|
116
|
+
(n - 1).times { d += 7 }
|
|
117
|
+
return nil if d.month != month
|
|
118
|
+
|
|
119
|
+
d
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.last_weekday_in_month(year, month, weekday)
|
|
123
|
+
target_dow = Weekday.number(weekday)
|
|
124
|
+
d = last_day_of_month(year, month)
|
|
125
|
+
d -= 1 while d.cwday != target_dow
|
|
126
|
+
d
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.weeks_between(a, b)
|
|
130
|
+
(b - a).to_i / 7
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.days_between(a, b)
|
|
134
|
+
(b - a).to_i
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.months_between_ym(a, b)
|
|
138
|
+
(b.year * 12) + b.month - ((a.year * 12) + a.month)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.is_excepted(d, exceptions)
|
|
142
|
+
exceptions.any? do |exc|
|
|
143
|
+
case exc
|
|
144
|
+
when NamedException
|
|
145
|
+
d.month == MonthName.number(exc.month) && d.day == exc.day
|
|
146
|
+
when IsoException
|
|
147
|
+
d == Date.parse(exc.date)
|
|
148
|
+
else
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.matches_during(d, during)
|
|
155
|
+
return true if during.empty?
|
|
156
|
+
|
|
157
|
+
during.any? { |mn| MonthName.number(mn) == d.month }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.next_during_month(d, during)
|
|
161
|
+
months = during.map { |mn| MonthName.number(mn) }.sort
|
|
162
|
+
|
|
163
|
+
months.each do |m|
|
|
164
|
+
return Date.new(d.year, m, 1) if m > d.month
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Wrap to first month of next year
|
|
168
|
+
Date.new(d.year + 1, months[0], 1)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.resolve_until(until_spec, now)
|
|
172
|
+
case until_spec
|
|
173
|
+
when IsoUntil
|
|
174
|
+
Date.parse(until_spec.date)
|
|
175
|
+
when NamedUntil
|
|
176
|
+
year = now.year
|
|
177
|
+
[year, year + 1].each do |y|
|
|
178
|
+
d = Date.new(y, MonthName.number(until_spec.month), until_spec.day)
|
|
179
|
+
return d if d >= now.to_date
|
|
180
|
+
rescue ArgumentError
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
Date.new(year + 1, MonthName.number(until_spec.month), until_spec.day)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.earliest_future_at_times(d, times, tz, now)
|
|
188
|
+
best = nil
|
|
189
|
+
times.each do |tod|
|
|
190
|
+
candidate = at_time_on_date(d, tod, tz)
|
|
191
|
+
next unless candidate
|
|
192
|
+
|
|
193
|
+
best = candidate if candidate > now && (best.nil? || candidate < best)
|
|
194
|
+
end
|
|
195
|
+
best
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Main evaluator class
|
|
200
|
+
class Evaluator
|
|
201
|
+
def self.next_from(schedule, now)
|
|
202
|
+
tz = TzResolver.resolve(schedule.timezone)
|
|
203
|
+
until_date = schedule.until ? EvalHelpers.resolve_until(schedule.until, now) : nil
|
|
204
|
+
has_exceptions = !schedule.except.empty?
|
|
205
|
+
has_during = !schedule.during.empty?
|
|
206
|
+
|
|
207
|
+
current = now
|
|
208
|
+
1000.times do
|
|
209
|
+
candidate = next_expr(schedule.expr, tz, schedule.anchor, current)
|
|
210
|
+
return nil unless candidate
|
|
211
|
+
|
|
212
|
+
c_date = candidate.to_date
|
|
213
|
+
|
|
214
|
+
# Apply until filter
|
|
215
|
+
return nil if until_date && c_date > until_date
|
|
216
|
+
|
|
217
|
+
# Apply during filter
|
|
218
|
+
if has_during && !EvalHelpers.matches_during(c_date, schedule.during)
|
|
219
|
+
skip_to = EvalHelpers.next_during_month(c_date, schedule.during)
|
|
220
|
+
midnight = EvalHelpers.at_time_on_date(skip_to, TimeOfDay.new(0, 0), tz)
|
|
221
|
+
current = midnight - 1
|
|
222
|
+
next
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Apply except filter
|
|
226
|
+
if has_exceptions && EvalHelpers.is_excepted(c_date, schedule.except)
|
|
227
|
+
next_day = c_date + 1
|
|
228
|
+
midnight = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
|
|
229
|
+
current = midnight - 1
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
return candidate
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.next_n_from(schedule, now, n)
|
|
240
|
+
results = []
|
|
241
|
+
current = now
|
|
242
|
+
n.times do
|
|
243
|
+
nxt = next_from(schedule, current)
|
|
244
|
+
break unless nxt
|
|
245
|
+
|
|
246
|
+
results << nxt
|
|
247
|
+
current = nxt + 60 # Add 1 minute
|
|
248
|
+
end
|
|
249
|
+
results
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.matches(schedule, dt)
|
|
253
|
+
tz = TzResolver.resolve(schedule.timezone)
|
|
254
|
+
# Convert to local time in the target timezone
|
|
255
|
+
dt_local = tz.utc_to_local(dt.utc)
|
|
256
|
+
d = dt_local.to_date
|
|
257
|
+
|
|
258
|
+
return false if !schedule.during.empty? && !EvalHelpers.matches_during(d, schedule.during)
|
|
259
|
+
return false if EvalHelpers.is_excepted(d, schedule.except)
|
|
260
|
+
|
|
261
|
+
if schedule.until
|
|
262
|
+
until_date = EvalHelpers.resolve_until(schedule.until, dt)
|
|
263
|
+
return false if d > until_date
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
matches_expr(schedule.expr, schedule.anchor, d, dt_local, tz)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def self.next_expr(expr, tz, anchor, now)
|
|
270
|
+
case expr
|
|
271
|
+
when DayRepeat
|
|
272
|
+
next_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
273
|
+
when IntervalRepeat
|
|
274
|
+
next_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
|
|
275
|
+
when WeekRepeat
|
|
276
|
+
next_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
277
|
+
when MonthRepeat
|
|
278
|
+
next_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
|
|
279
|
+
when OrdinalRepeat
|
|
280
|
+
next_ordinal_repeat(expr.interval, expr.ordinal, expr.day, expr.times, tz, anchor, now)
|
|
281
|
+
when SingleDateExpr
|
|
282
|
+
next_single_date(expr.date, expr.times, tz, now)
|
|
283
|
+
when YearRepeat
|
|
284
|
+
next_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def self.matches_expr(expr, anchor, d, dt, tz)
|
|
289
|
+
time_matches = ->(times) { time_matches_with_dst(times, d, dt, tz) }
|
|
290
|
+
|
|
291
|
+
case expr
|
|
292
|
+
when DayRepeat
|
|
293
|
+
return false unless EvalHelpers.matches_day_filter(d, expr.days)
|
|
294
|
+
return false unless time_matches.call(expr.times)
|
|
295
|
+
|
|
296
|
+
if expr.interval > 1
|
|
297
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
298
|
+
day_offset = EvalHelpers.days_between(anchor_date, d)
|
|
299
|
+
return day_offset >= 0 && (day_offset % expr.interval).zero?
|
|
300
|
+
end
|
|
301
|
+
true
|
|
302
|
+
|
|
303
|
+
when IntervalRepeat
|
|
304
|
+
return false if expr.day_filter && !EvalHelpers.matches_day_filter(d, expr.day_filter)
|
|
305
|
+
|
|
306
|
+
from_minutes = (expr.from_time.hour * 60) + expr.from_time.minute
|
|
307
|
+
to_minutes = (expr.to_time.hour * 60) + expr.to_time.minute
|
|
308
|
+
current_minutes = (dt.hour * 60) + dt.min
|
|
309
|
+
return false if current_minutes < from_minutes || current_minutes > to_minutes
|
|
310
|
+
|
|
311
|
+
diff = current_minutes - from_minutes
|
|
312
|
+
step = (expr.unit == IntervalUnit::MIN) ? expr.interval : expr.interval * 60
|
|
313
|
+
diff >= 0 && (diff % step).zero?
|
|
314
|
+
|
|
315
|
+
when WeekRepeat
|
|
316
|
+
dow = d.cwday
|
|
317
|
+
return false unless expr.days.any? { |wd| Weekday.number(wd) == dow }
|
|
318
|
+
return false unless time_matches.call(expr.times)
|
|
319
|
+
|
|
320
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
|
|
321
|
+
weeks = EvalHelpers.weeks_between(anchor_date, d)
|
|
322
|
+
weeks >= 0 && (weeks % expr.interval).zero?
|
|
323
|
+
|
|
324
|
+
when MonthRepeat
|
|
325
|
+
return false unless time_matches.call(expr.times)
|
|
326
|
+
|
|
327
|
+
if expr.interval > 1
|
|
328
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
329
|
+
month_offset = EvalHelpers.months_between_ym(anchor_date, d)
|
|
330
|
+
return false if month_offset.negative? || (month_offset % expr.interval) != 0
|
|
331
|
+
end
|
|
332
|
+
matches_month_target(expr.target, d)
|
|
333
|
+
|
|
334
|
+
when OrdinalRepeat
|
|
335
|
+
return false unless time_matches.call(expr.times)
|
|
336
|
+
|
|
337
|
+
if expr.interval > 1
|
|
338
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
339
|
+
month_offset = EvalHelpers.months_between_ym(anchor_date, d)
|
|
340
|
+
return false if month_offset.negative? || (month_offset % expr.interval) != 0
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
ordinal_target = if expr.ordinal == OrdinalPosition::LAST
|
|
344
|
+
EvalHelpers.last_weekday_in_month(d.year, d.month, expr.day)
|
|
345
|
+
else
|
|
346
|
+
EvalHelpers.nth_weekday_of_month(d.year, d.month, expr.day,
|
|
347
|
+
OrdinalPosition.to_n(expr.ordinal))
|
|
348
|
+
end
|
|
349
|
+
return false unless ordinal_target
|
|
350
|
+
|
|
351
|
+
d == ordinal_target
|
|
352
|
+
|
|
353
|
+
when SingleDateExpr
|
|
354
|
+
return false unless time_matches.call(expr.times)
|
|
355
|
+
|
|
356
|
+
matches_date_spec(expr.date, d)
|
|
357
|
+
|
|
358
|
+
when YearRepeat
|
|
359
|
+
return false unless time_matches.call(expr.times)
|
|
360
|
+
|
|
361
|
+
if expr.interval > 1
|
|
362
|
+
anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
|
|
363
|
+
year_offset = d.year - anchor_year
|
|
364
|
+
return false if year_offset.negative? || (year_offset % expr.interval) != 0
|
|
365
|
+
end
|
|
366
|
+
matches_year_target(expr.target, d)
|
|
367
|
+
|
|
368
|
+
else
|
|
369
|
+
false
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def self.time_matches_with_dst(times, d, dt, tz)
|
|
374
|
+
times.any? do |tod|
|
|
375
|
+
if dt.hour == tod.hour && dt.min == tod.minute
|
|
376
|
+
true
|
|
377
|
+
else
|
|
378
|
+
# DST gap check
|
|
379
|
+
resolved = EvalHelpers.at_time_on_date(d, tod, tz)
|
|
380
|
+
resolved && resolved.to_i == dt.to_i
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def self.matches_month_target(target, d)
|
|
386
|
+
case target
|
|
387
|
+
when DaysTarget
|
|
388
|
+
expanded = Hron.expand_month_target(target)
|
|
389
|
+
expanded.include?(d.day)
|
|
390
|
+
when LastDayTarget
|
|
391
|
+
d == EvalHelpers.last_day_of_month(d.year, d.month)
|
|
392
|
+
when LastWeekdayTarget
|
|
393
|
+
d == EvalHelpers.last_weekday_of_month(d.year, d.month)
|
|
394
|
+
else
|
|
395
|
+
false
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def self.matches_date_spec(date_spec, d)
|
|
400
|
+
case date_spec
|
|
401
|
+
when IsoDate
|
|
402
|
+
d == Date.parse(date_spec.date)
|
|
403
|
+
when NamedDate
|
|
404
|
+
d.month == MonthName.number(date_spec.month) && d.day == date_spec.day
|
|
405
|
+
else
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def self.matches_year_target(target, d)
|
|
411
|
+
case target
|
|
412
|
+
when YearDateTarget
|
|
413
|
+
d.month == MonthName.number(target.month) && d.day == target.day
|
|
414
|
+
when YearOrdinalWeekdayTarget
|
|
415
|
+
return false if d.month != MonthName.number(target.month)
|
|
416
|
+
|
|
417
|
+
ordinal_date = if target.ordinal == OrdinalPosition::LAST
|
|
418
|
+
EvalHelpers.last_weekday_in_month(d.year, d.month, target.weekday)
|
|
419
|
+
else
|
|
420
|
+
EvalHelpers.nth_weekday_of_month(d.year, d.month, target.weekday,
|
|
421
|
+
OrdinalPosition.to_n(target.ordinal))
|
|
422
|
+
end
|
|
423
|
+
ordinal_date && d == ordinal_date
|
|
424
|
+
when YearDayOfMonthTarget
|
|
425
|
+
d.month == MonthName.number(target.month) && d.day == target.day
|
|
426
|
+
when YearLastWeekdayTarget
|
|
427
|
+
return false if d.month != MonthName.number(target.month)
|
|
428
|
+
|
|
429
|
+
d == EvalHelpers.last_weekday_of_month(d.year, d.month)
|
|
430
|
+
else
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Per-variant next functions
|
|
436
|
+
def self.next_day_repeat(interval, days, times, tz, anchor, now)
|
|
437
|
+
now_local = tz.utc_to_local(now.utc)
|
|
438
|
+
d = now_local.to_date
|
|
439
|
+
|
|
440
|
+
if interval <= 1
|
|
441
|
+
if EvalHelpers.matches_day_filter(d, days)
|
|
442
|
+
candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
443
|
+
return candidate if candidate
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
8.times do
|
|
447
|
+
d += 1
|
|
448
|
+
if EvalHelpers.matches_day_filter(d, days)
|
|
449
|
+
candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
450
|
+
return candidate if candidate
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
return nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Interval > 1
|
|
458
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
459
|
+
offset = EvalHelpers.days_between(anchor_date, d)
|
|
460
|
+
remainder = offset % interval
|
|
461
|
+
aligned_date = remainder.zero? ? d : d + (interval - remainder)
|
|
462
|
+
|
|
463
|
+
400.times do
|
|
464
|
+
candidate = EvalHelpers.earliest_future_at_times(aligned_date, times, tz, now)
|
|
465
|
+
return candidate if candidate
|
|
466
|
+
|
|
467
|
+
aligned_date += interval
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def self.next_interval_repeat(interval, unit, from_time, to_time, day_filter, tz, now)
|
|
474
|
+
step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
|
|
475
|
+
from_minutes = (from_time.hour * 60) + from_time.minute
|
|
476
|
+
to_minutes = (to_time.hour * 60) + to_time.minute
|
|
477
|
+
|
|
478
|
+
# Convert now to local time in the target timezone
|
|
479
|
+
now_local = tz.utc_to_local(now.utc)
|
|
480
|
+
d = now_local.to_date
|
|
481
|
+
|
|
482
|
+
400.times do
|
|
483
|
+
if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
|
|
484
|
+
d += 1
|
|
485
|
+
next
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
same_day = d == now_local.to_date
|
|
489
|
+
now_minutes = same_day ? (now_local.hour * 60) + now_local.min : -1
|
|
490
|
+
|
|
491
|
+
next_slot = if now_minutes < from_minutes
|
|
492
|
+
from_minutes
|
|
493
|
+
else
|
|
494
|
+
elapsed = now_minutes - from_minutes
|
|
495
|
+
from_minutes + (((elapsed / step_minutes) + 1) * step_minutes)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Try each slot within the day's window until we find one that exists
|
|
499
|
+
# This handles DST gaps where intermediate times don't exist
|
|
500
|
+
while next_slot <= to_minutes
|
|
501
|
+
h = next_slot / 60
|
|
502
|
+
m = next_slot % 60
|
|
503
|
+
candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, m), tz)
|
|
504
|
+
return candidate if candidate && candidate > now
|
|
505
|
+
|
|
506
|
+
# Slot didn't exist (DST gap) or wasn't in the future, try next slot
|
|
507
|
+
next_slot += step_minutes
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
d += 1
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def self.next_week_repeat(interval, days, times, tz, anchor, now)
|
|
517
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
|
|
518
|
+
|
|
519
|
+
now_local = tz.utc_to_local(now.utc)
|
|
520
|
+
d = now_local.to_date
|
|
521
|
+
sorted_days = days.sort_by { |wd| Weekday.number(wd) }
|
|
522
|
+
|
|
523
|
+
dow_offset = d.cwday - 1
|
|
524
|
+
current_monday = d - dow_offset
|
|
525
|
+
|
|
526
|
+
anchor_dow_offset = anchor_date.cwday - 1
|
|
527
|
+
anchor_monday = anchor_date - anchor_dow_offset
|
|
528
|
+
|
|
529
|
+
54.times do
|
|
530
|
+
weeks = EvalHelpers.weeks_between(anchor_monday, current_monday)
|
|
531
|
+
|
|
532
|
+
if weeks.negative?
|
|
533
|
+
skip = (-weeks + interval - 1) / interval
|
|
534
|
+
current_monday += skip * interval * 7
|
|
535
|
+
next
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
if (weeks % interval).zero?
|
|
539
|
+
sorted_days.each do |wd|
|
|
540
|
+
day_offset = Weekday.number(wd) - 1
|
|
541
|
+
target_date = current_monday + day_offset
|
|
542
|
+
candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
|
|
543
|
+
return candidate if candidate
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
remainder = weeks % interval
|
|
548
|
+
skip_weeks = remainder.zero? ? interval : interval - remainder
|
|
549
|
+
current_monday += skip_weeks * 7
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def self.next_month_repeat(interval, target, times, tz, anchor, now)
|
|
556
|
+
now_local = tz.utc_to_local(now.utc)
|
|
557
|
+
year = now_local.year
|
|
558
|
+
month = now_local.month
|
|
559
|
+
|
|
560
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
561
|
+
max_iter = (interval > 1) ? 24 * interval : 24
|
|
562
|
+
|
|
563
|
+
max_iter.times do
|
|
564
|
+
if interval > 1
|
|
565
|
+
cur = Date.new(year, month, 1)
|
|
566
|
+
month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
|
|
567
|
+
if month_offset.negative? || (month_offset % interval) != 0
|
|
568
|
+
month += 1
|
|
569
|
+
if month > 12
|
|
570
|
+
month = 1
|
|
571
|
+
year += 1
|
|
572
|
+
end
|
|
573
|
+
next
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
date_candidates = []
|
|
578
|
+
|
|
579
|
+
case target
|
|
580
|
+
when DaysTarget
|
|
581
|
+
expanded = Hron.expand_month_target(target)
|
|
582
|
+
last = EvalHelpers.last_day_of_month(year, month)
|
|
583
|
+
expanded.each do |day_num|
|
|
584
|
+
next unless day_num <= last.day
|
|
585
|
+
|
|
586
|
+
begin
|
|
587
|
+
date_candidates << Date.new(year, month, day_num)
|
|
588
|
+
rescue ArgumentError
|
|
589
|
+
# Invalid date
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
when LastDayTarget
|
|
593
|
+
date_candidates << EvalHelpers.last_day_of_month(year, month)
|
|
594
|
+
when LastWeekdayTarget
|
|
595
|
+
date_candidates << EvalHelpers.last_weekday_of_month(year, month)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
best = nil
|
|
599
|
+
date_candidates.each do |dc|
|
|
600
|
+
candidate = EvalHelpers.earliest_future_at_times(dc, times, tz, now)
|
|
601
|
+
best = candidate if candidate && (best.nil? || candidate < best)
|
|
602
|
+
end
|
|
603
|
+
return best if best
|
|
604
|
+
|
|
605
|
+
month += 1
|
|
606
|
+
if month > 12
|
|
607
|
+
month = 1
|
|
608
|
+
year += 1
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
nil
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def self.next_ordinal_repeat(interval, ordinal, day, times, tz, anchor, now)
|
|
616
|
+
now_local = tz.utc_to_local(now.utc)
|
|
617
|
+
year = now_local.year
|
|
618
|
+
month = now_local.month
|
|
619
|
+
|
|
620
|
+
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
621
|
+
max_iter = (interval > 1) ? 24 * interval : 24
|
|
622
|
+
|
|
623
|
+
max_iter.times do
|
|
624
|
+
if interval > 1
|
|
625
|
+
cur = Date.new(year, month, 1)
|
|
626
|
+
month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
|
|
627
|
+
if month_offset.negative? || (month_offset % interval) != 0
|
|
628
|
+
month += 1
|
|
629
|
+
if month > 12
|
|
630
|
+
month = 1
|
|
631
|
+
year += 1
|
|
632
|
+
end
|
|
633
|
+
next
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
ordinal_date = if ordinal == OrdinalPosition::LAST
|
|
638
|
+
EvalHelpers.last_weekday_in_month(year, month, day)
|
|
639
|
+
else
|
|
640
|
+
EvalHelpers.nth_weekday_of_month(year, month, day, OrdinalPosition.to_n(ordinal))
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
if ordinal_date
|
|
644
|
+
candidate = EvalHelpers.earliest_future_at_times(ordinal_date, times, tz, now)
|
|
645
|
+
return candidate if candidate
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
month += 1
|
|
649
|
+
if month > 12
|
|
650
|
+
month = 1
|
|
651
|
+
year += 1
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def self.next_single_date(date_spec, times, tz, now)
|
|
659
|
+
case date_spec
|
|
660
|
+
when IsoDate
|
|
661
|
+
d = Date.parse(date_spec.date)
|
|
662
|
+
EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
663
|
+
when NamedDate
|
|
664
|
+
now_local = tz.utc_to_local(now.utc)
|
|
665
|
+
start_year = now_local.year
|
|
666
|
+
8.times do |y|
|
|
667
|
+
year = start_year + y
|
|
668
|
+
begin
|
|
669
|
+
d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
|
|
670
|
+
candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
671
|
+
return candidate if candidate
|
|
672
|
+
rescue ArgumentError
|
|
673
|
+
# Invalid date
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
nil
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def self.next_year_repeat(interval, target, times, tz, anchor, now)
|
|
681
|
+
now_local = tz.utc_to_local(now.utc)
|
|
682
|
+
start_year = now_local.year
|
|
683
|
+
anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
|
|
684
|
+
|
|
685
|
+
max_iter = (interval > 1) ? 8 * interval : 8
|
|
686
|
+
|
|
687
|
+
max_iter.times do |y|
|
|
688
|
+
year = start_year + y
|
|
689
|
+
|
|
690
|
+
if interval > 1
|
|
691
|
+
year_offset = year - anchor_year
|
|
692
|
+
next if year_offset.negative? || (year_offset % interval) != 0
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
target_date = compute_year_target_date(target, year)
|
|
696
|
+
next unless target_date
|
|
697
|
+
|
|
698
|
+
candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
|
|
699
|
+
return candidate if candidate
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def self.compute_year_target_date(target, year)
|
|
706
|
+
case target
|
|
707
|
+
when YearDateTarget
|
|
708
|
+
Date.new(year, MonthName.number(target.month), target.day)
|
|
709
|
+
when YearOrdinalWeekdayTarget
|
|
710
|
+
if target.ordinal == OrdinalPosition::LAST
|
|
711
|
+
EvalHelpers.last_weekday_in_month(year, MonthName.number(target.month), target.weekday)
|
|
712
|
+
else
|
|
713
|
+
EvalHelpers.nth_weekday_of_month(year, MonthName.number(target.month), target.weekday,
|
|
714
|
+
OrdinalPosition.to_n(target.ordinal))
|
|
715
|
+
end
|
|
716
|
+
when YearDayOfMonthTarget
|
|
717
|
+
Date.new(year, MonthName.number(target.month), target.day)
|
|
718
|
+
when YearLastWeekdayTarget
|
|
719
|
+
EvalHelpers.last_weekday_of_month(year, MonthName.number(target.month))
|
|
720
|
+
end
|
|
721
|
+
rescue ArgumentError
|
|
722
|
+
nil
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|