hron 1.0.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 +126 -21
- data/hron.gemspec +7 -11
- data/lib/hron/ast.rb +28 -54
- data/lib/hron/cron.rb +369 -652
- data/lib/hron/display.rb +6 -4
- data/lib/hron/error.rb +54 -17
- data/lib/hron/eval/calendar.rb +120 -0
- data/lib/hron/eval/wall_clock.rb +65 -0
- data/lib/hron/evaluator.rb +339 -1010
- data/lib/hron/lexer.rb +84 -94
- data/lib/hron/parser.rb +369 -502
- data/lib/hron/parts.rb +257 -0
- data/lib/hron/schedule.rb +81 -19
- data/lib/hron/version.rb +1 -1
- data/lib/hron.rb +13 -3
- metadata +14 -53
data/lib/hron/evaluator.rb
CHANGED
|
@@ -5,1146 +5,475 @@ require "time"
|
|
|
5
5
|
require "tzinfo"
|
|
6
6
|
require_relative "ast"
|
|
7
7
|
require_relative "error"
|
|
8
|
+
require_relative "eval/calendar"
|
|
9
|
+
require_relative "eval/wall_clock"
|
|
8
10
|
|
|
9
11
|
module Hron
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# MAX_ITERATIONS (1000): Maximum iterations for next_from/previous_from loops.
|
|
14
|
-
# Prevents infinite loops when searching for valid occurrences.
|
|
15
|
-
#
|
|
16
|
-
# Expression-specific limits:
|
|
17
|
-
# - Day repeat: 8 days (covers one week + margin)
|
|
18
|
-
# - Week repeat: 54 weeks (covers one year + margin)
|
|
19
|
-
# - Month repeat: 24 * interval months (covers 2 years scaled by interval)
|
|
20
|
-
# - Year repeat: 8 * interval years (covers reasonable future horizon)
|
|
21
|
-
#
|
|
22
|
-
# These limits are generous safety bounds. In practice, valid schedules
|
|
23
|
-
# find occurrences within the first few iterations.
|
|
24
|
-
# =============================================================================
|
|
25
|
-
|
|
26
|
-
# =============================================================================
|
|
27
|
-
# DST (Daylight Saving Time) Handling
|
|
28
|
-
# =============================================================================
|
|
29
|
-
# When resolving a wall-clock time to an instant:
|
|
30
|
-
#
|
|
31
|
-
# 1. DST Gap (Spring Forward):
|
|
32
|
-
# - Time doesn't exist (e.g., 2:30 AM during spring forward)
|
|
33
|
-
# - Solution: Push forward to the next valid time after the gap
|
|
34
|
-
# - Example: 2:30 AM -> 3:00 AM (or 3:30 AM depending on gap size)
|
|
35
|
-
#
|
|
36
|
-
# 2. DST Fold (Fall Back):
|
|
37
|
-
# - Time is ambiguous (e.g., 1:30 AM occurs twice)
|
|
38
|
-
# - Solution: Use first occurrence (fold=0 / pre-transition time)
|
|
39
|
-
# - This matches user expectation for scheduling
|
|
40
|
-
#
|
|
41
|
-
# All implementations use the same algorithm for cross-language consistency.
|
|
42
|
-
# =============================================================================
|
|
43
|
-
|
|
44
|
-
# =============================================================================
|
|
45
|
-
# Interval Alignment (Anchor Date)
|
|
46
|
-
# =============================================================================
|
|
47
|
-
# For schedules with interval > 1 (e.g., "every 3 days"), we need to
|
|
48
|
-
# determine which dates are valid based on alignment with an anchor.
|
|
49
|
-
#
|
|
50
|
-
# Formula: (date_offset - anchor_offset) mod interval == 0
|
|
51
|
-
#
|
|
52
|
-
# Where:
|
|
53
|
-
# - date_offset: days/weeks/months from epoch to candidate date
|
|
54
|
-
# - anchor_offset: days/weeks/months from epoch to anchor date
|
|
55
|
-
# - interval: the repeat interval (e.g., 3 for "every 3 days")
|
|
56
|
-
#
|
|
57
|
-
# Default anchor: Epoch (1970-01-01)
|
|
58
|
-
# Custom anchor: Set via "starting YYYY-MM-DD" clause
|
|
59
|
-
#
|
|
60
|
-
# For week repeats, we use epoch Monday (1970-01-05) as the reference
|
|
61
|
-
# point to align week boundaries correctly.
|
|
62
|
-
# =============================================================================
|
|
63
|
-
|
|
64
|
-
EPOCH_DATE = Date.new(1970, 1, 1)
|
|
65
|
-
EPOCH_MONDAY = Date.new(1970, 1, 5)
|
|
66
|
-
|
|
67
|
-
# Timezone resolution
|
|
68
|
-
module TzResolver
|
|
69
|
-
def self.resolve(tz_name)
|
|
70
|
-
if tz_name && !tz_name.empty?
|
|
71
|
-
TZInfo::Timezone.get(tz_name)
|
|
72
|
-
else
|
|
73
|
-
# Default to UTC for deterministic, portable behavior
|
|
74
|
-
TZInfo::Timezone.get("UTC")
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
12
|
+
class Evaluator
|
|
13
|
+
# Default anchor for week intervals (spec/README.md, "WeekRepeat epoch alignment").
|
|
14
|
+
EPOCH_MONDAY = Date.new(1970, 1, 5, Date::GREGORIAN)
|
|
78
15
|
|
|
79
|
-
|
|
80
|
-
module EvalHelpers
|
|
81
|
-
def self.at_time_on_date(d, tod, tz)
|
|
82
|
-
# Create local time representation (using UTC to avoid system TZ interference)
|
|
83
|
-
local_time = Time.utc(d.year, d.month, d.day, tod.hour, tod.minute, 0)
|
|
84
|
-
|
|
85
|
-
# Get periods for this local time
|
|
86
|
-
periods = tz.periods_for_local(local_time)
|
|
87
|
-
|
|
88
|
-
case periods.length
|
|
89
|
-
when 0
|
|
90
|
-
# Time doesn't exist (spring forward gap)
|
|
91
|
-
# Push the time forward past the gap (like "compatible" disambiguation)
|
|
92
|
-
# For example: 02:30 during DST spring forward -> 03:30
|
|
93
|
-
|
|
94
|
-
# Find the transition on this date
|
|
95
|
-
day_start = Time.utc(d.year, d.month, d.day, 0, 0, 0)
|
|
96
|
-
day_end = Time.utc(d.year, d.month, d.day, 23, 59, 59)
|
|
97
|
-
transitions = tz.transitions_up_to(day_end, day_start)
|
|
98
|
-
|
|
99
|
-
if transitions.any?
|
|
100
|
-
# Find the spring-forward transition (where offset increases / DST starts)
|
|
101
|
-
transition = transitions.find { |t| t.offset.dst? }
|
|
102
|
-
if transition
|
|
103
|
-
# The transition time is when the gap starts
|
|
104
|
-
# We need to push the requested time forward by the gap size
|
|
105
|
-
prev_offset = transition.previous_offset.utc_total_offset
|
|
106
|
-
new_offset = transition.offset.utc_total_offset
|
|
107
|
-
gap_seconds = new_offset - prev_offset # Positive for spring forward
|
|
108
|
-
|
|
109
|
-
# Push the local time forward by the gap amount
|
|
110
|
-
# E.g., 02:30 + 1 hour = 03:30 local
|
|
111
|
-
pushed_local = local_time + gap_seconds
|
|
112
|
-
|
|
113
|
-
# Convert the pushed local time to UTC using the new offset
|
|
114
|
-
# E.g., 03:30 local EDT (-4h) -> 07:30 UTC
|
|
115
|
-
return pushed_local - new_offset
|
|
116
|
-
end
|
|
117
|
-
end
|
|
16
|
+
EPOCH_DATE = Date.new(1970, 1, 1, Date::GREGORIAN)
|
|
118
17
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
begin
|
|
122
|
-
tz.local_to_utc(local_time)
|
|
123
|
-
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound
|
|
124
|
-
nil
|
|
125
|
-
end
|
|
126
|
-
when 1
|
|
127
|
-
# Unambiguous time - straightforward conversion
|
|
128
|
-
period = periods[0]
|
|
129
|
-
utc_offset = period.offset.utc_total_offset
|
|
130
|
-
local_time - utc_offset
|
|
131
|
-
when 2
|
|
132
|
-
# Ambiguous time (fall back) - use first occurrence (pre-transition)
|
|
133
|
-
# Period 0 is the earlier offset (e.g., EDT -04:00 before fall back)
|
|
134
|
-
period = periods[0]
|
|
135
|
-
utc_offset = period.offset.utc_total_offset
|
|
136
|
-
local_time - utc_offset
|
|
137
|
-
end
|
|
138
|
-
end
|
|
18
|
+
# spec/README.md, "Supported range".
|
|
19
|
+
SUPPORTED_RANGE = Time.utc(1, 1, 2)...Time.utc(9999, 12, 30)
|
|
139
20
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
when DayFilterEvery
|
|
144
|
-
true
|
|
145
|
-
when DayFilterWeekday
|
|
146
|
-
dow.between?(1, 5)
|
|
147
|
-
when DayFilterWeekend
|
|
148
|
-
[6, 7].include?(dow)
|
|
149
|
-
when DayFilterDays
|
|
150
|
-
filter.days.any? { |wd| Weekday.number(wd) == dow }
|
|
151
|
-
else
|
|
152
|
-
false
|
|
153
|
-
end
|
|
154
|
-
end
|
|
21
|
+
# Slack beyond the horizon for the period one behind the first date's, where a
|
|
22
|
+
# search starts, and for a horizon that starts mid-period.
|
|
23
|
+
HORIZON_MARGIN_PERIODS = 2
|
|
155
24
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
25
|
+
# How many dates past its scheduled date a fixed time can land: one shifted out of a gap
|
|
26
|
+
# before midnight lands on the next date.
|
|
27
|
+
MAX_SHIFT_DAYS = 1
|
|
159
28
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
d
|
|
164
|
-
end
|
|
29
|
+
# How many dates behind a date that has begun now's wall date can read: from the second
|
|
30
|
+
# pass of a fall-back overlap that crosses midnight, one.
|
|
31
|
+
MAX_OVERLAP_DAYS = 1
|
|
165
32
|
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
# - direction=NearestDirection::NEXT: always prefer following weekday (can cross to next month)
|
|
169
|
-
# - direction=NearestDirection::PREVIOUS: always prefer preceding weekday (can cross to prev month)
|
|
170
|
-
# Returns nil if the target_day doesn't exist in the month (e.g., day 31 in February).
|
|
171
|
-
def self.nearest_weekday(year, month, target_day, direction)
|
|
172
|
-
last = last_day_of_month(year, month)
|
|
173
|
-
last_day_num = last.day
|
|
174
|
-
|
|
175
|
-
# If target day doesn't exist in this month, return nil (skip this month)
|
|
176
|
-
return nil if target_day > last_day_num
|
|
177
|
-
|
|
178
|
-
date = Date.new(year, month, target_day)
|
|
179
|
-
dow = date.cwday # Monday=1 ... Sunday=7
|
|
180
|
-
|
|
181
|
-
# Already a weekday (Mon-Fri = 1-5)
|
|
182
|
-
return date if dow.between?(1, 5)
|
|
183
|
-
|
|
184
|
-
case direction
|
|
185
|
-
when nil
|
|
186
|
-
# Standard cron W behavior: never cross month boundary
|
|
187
|
-
if dow == 6 && target_day == 1
|
|
188
|
-
# Saturday at 1st: can't go to previous month, use Monday (day 3)
|
|
189
|
-
date + 2
|
|
190
|
-
elsif dow == 6
|
|
191
|
-
# Saturday: Friday
|
|
192
|
-
date - 1
|
|
193
|
-
elsif target_day >= last_day_num
|
|
194
|
-
# Sunday at end of month: can't go to next month, use Friday
|
|
195
|
-
date - 2
|
|
196
|
-
else
|
|
197
|
-
# Sunday: Monday
|
|
198
|
-
date + 1
|
|
199
|
-
end
|
|
33
|
+
# Feb 29 can be eight years away, as from 2096-03-01 to 2104-02-29.
|
|
34
|
+
NAMED_UNTIL_MAX_YEARS = 8
|
|
200
35
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
end
|
|
36
|
+
# interval_slots and days_of are shared with Cron.to_cron, so conversion never disagrees with
|
|
37
|
+
# evaluation.
|
|
38
|
+
def self.interval_slots(interval, unit, from, to)
|
|
39
|
+
step = (unit == IntervalUnit::MIN) ? interval : interval * WallClock::MINUTES_PER_HOUR
|
|
40
|
+
(WallClock.minute_of_day(from)..WallClock.minute_of_day(to)).step(step).to_a
|
|
41
|
+
end
|
|
208
42
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
date - 2
|
|
43
|
+
def self.days_of(target)
|
|
44
|
+
target.specs.flat_map do |spec|
|
|
45
|
+
case spec
|
|
46
|
+
when SingleDay then [spec.day]
|
|
47
|
+
when DayRange then (spec.start..spec.end_day).to_a
|
|
215
48
|
end
|
|
216
49
|
end
|
|
217
50
|
end
|
|
218
51
|
|
|
219
|
-
def self.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
d += 1 while d.cwday != target_dow
|
|
223
|
-
(n - 1).times { d += 7 }
|
|
224
|
-
return nil if d.month != month
|
|
225
|
-
|
|
226
|
-
d
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def self.last_weekday_in_month(year, month, weekday)
|
|
230
|
-
target_dow = Weekday.number(weekday)
|
|
231
|
-
d = last_day_of_month(year, month)
|
|
232
|
-
d -= 1 while d.cwday != target_dow
|
|
233
|
-
d
|
|
52
|
+
def self.next_from(schedule, now)
|
|
53
|
+
require_time(now, "now")
|
|
54
|
+
Search.new(schedule).nearest(now, Direction::FORWARD) if SUPPORTED_RANGE.cover?(now)
|
|
234
55
|
end
|
|
235
56
|
|
|
236
|
-
def self.
|
|
237
|
-
(
|
|
57
|
+
def self.previous_from(schedule, now)
|
|
58
|
+
require_time(now, "now")
|
|
59
|
+
Search.new(schedule).nearest(now, Direction::BACKWARD) if SUPPORTED_RANGE.cover?(now)
|
|
238
60
|
end
|
|
239
61
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
62
|
+
# Not Enumerable#first, which raises RangeError for an n past a machine integer, where
|
|
63
|
+
# n only caps the count (spec/README.md, "Timestamps and counts").
|
|
64
|
+
def self.next_n_from(schedule, now, n)
|
|
65
|
+
require_time(now, "now")
|
|
66
|
+
raise TypeError, "n must be an Integer, not #{n.class}" unless n.is_a?(Integer)
|
|
243
67
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
end
|
|
68
|
+
results = []
|
|
69
|
+
return results unless n.positive?
|
|
247
70
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
when NamedException
|
|
252
|
-
d.month == MonthName.number(exc.month) && d.day == exc.day
|
|
253
|
-
when IsoException
|
|
254
|
-
d == Date.parse(exc.date)
|
|
255
|
-
else
|
|
256
|
-
false
|
|
257
|
-
end
|
|
71
|
+
occurrences(schedule, now).each do |occurrence|
|
|
72
|
+
results << occurrence
|
|
73
|
+
break if results.size == n
|
|
258
74
|
end
|
|
75
|
+
results
|
|
259
76
|
end
|
|
260
77
|
|
|
261
|
-
def self.
|
|
262
|
-
|
|
78
|
+
def self.occurrences(schedule, from)
|
|
79
|
+
require_time(from, "from")
|
|
80
|
+
Enumerator.new do |yielder|
|
|
81
|
+
next unless SUPPORTED_RANGE.cover?(from)
|
|
263
82
|
|
|
264
|
-
|
|
83
|
+
search = Search.new(schedule)
|
|
84
|
+
current = from
|
|
85
|
+
while (current = search.nearest(current, Direction::FORWARD))
|
|
86
|
+
yielder << current
|
|
87
|
+
end
|
|
88
|
+
end.lazy
|
|
265
89
|
end
|
|
266
90
|
|
|
267
|
-
def self.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
end
|
|
91
|
+
def self.between(schedule, from, to)
|
|
92
|
+
require_time(from, "from")
|
|
93
|
+
require_time(to, "to")
|
|
94
|
+
Enumerator.new do |yielder|
|
|
95
|
+
next unless SUPPORTED_RANGE.cover?(to)
|
|
273
96
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
end
|
|
97
|
+
occurrences(schedule, from).each do |instant|
|
|
98
|
+
break if instant > to
|
|
277
99
|
|
|
278
|
-
|
|
279
|
-
case until_spec
|
|
280
|
-
when IsoUntil
|
|
281
|
-
Date.parse(until_spec.date)
|
|
282
|
-
when NamedUntil
|
|
283
|
-
year = now.year
|
|
284
|
-
[year, year + 1].each do |y|
|
|
285
|
-
d = Date.new(y, MonthName.number(until_spec.month), until_spec.day)
|
|
286
|
-
return d if d >= now.to_date
|
|
287
|
-
rescue ArgumentError
|
|
288
|
-
next
|
|
100
|
+
yielder << instant
|
|
289
101
|
end
|
|
290
|
-
|
|
291
|
-
end
|
|
102
|
+
end.lazy
|
|
292
103
|
end
|
|
293
104
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
105
|
+
# Defined through the forward search, so the two cannot disagree (spec/README.md,
|
|
106
|
+
# "matches is true exactly when the minute containing t is an occurrence").
|
|
107
|
+
def self.matches(schedule, dt)
|
|
108
|
+
require_time(dt, "datetime")
|
|
109
|
+
return false unless SUPPORTED_RANGE.cover?(dt)
|
|
299
110
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
111
|
+
search = Search.new(schedule)
|
|
112
|
+
local = search.zone.to_local(dt)
|
|
113
|
+
minute = dt - local.sec - local.subsec
|
|
114
|
+
# Occurrences fall on whole seconds, so none lies between this and the minute.
|
|
115
|
+
just_before = minute - 1
|
|
116
|
+
# An occurrence never lands before the date it is scheduled on, so one at this minute
|
|
117
|
+
# is scheduled on or before the minute's wall date.
|
|
118
|
+
search.clauses.end_on(local.to_date.gregorian)
|
|
119
|
+
search.nearest(just_before, Direction::FORWARD) == minute
|
|
303
120
|
end
|
|
304
121
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
candidate = at_time_on_date(d, tod, tz)
|
|
309
|
-
next unless candidate
|
|
310
|
-
|
|
311
|
-
best = candidate if candidate < now && (best.nil? || candidate > best)
|
|
312
|
-
end
|
|
313
|
-
best
|
|
122
|
+
# A usage error, never a HronError (spec/README.md, "Timestamps and counts").
|
|
123
|
+
def self.require_time(value, name)
|
|
124
|
+
raise TypeError, "#{name} must be a Time, not #{value.class}" unless value.is_a?(Time)
|
|
314
125
|
end
|
|
126
|
+
private_class_method :require_time
|
|
315
127
|
|
|
316
|
-
|
|
317
|
-
|
|
128
|
+
class Direction
|
|
129
|
+
attr_reader :sign
|
|
318
130
|
|
|
319
|
-
|
|
320
|
-
|
|
131
|
+
def initialize(sign)
|
|
132
|
+
@sign = sign
|
|
133
|
+
@forward = sign.positive?
|
|
321
134
|
end
|
|
322
135
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
nil
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
# Main evaluator class
|
|
331
|
-
class Evaluator
|
|
332
|
-
def self.next_from(schedule, now)
|
|
333
|
-
tz = TzResolver.resolve(schedule.timezone)
|
|
334
|
-
until_date = schedule.until ? EvalHelpers.resolve_until(schedule.until, now) : nil
|
|
335
|
-
has_exceptions = !schedule.except.empty?
|
|
336
|
-
has_during = !schedule.during.empty?
|
|
337
|
-
|
|
338
|
-
# Check if expression is NearestWeekday with direction (can cross month boundaries)
|
|
339
|
-
handles_during_internally = schedule.expr.is_a?(MonthRepeat) &&
|
|
340
|
-
schedule.expr.target.is_a?(NearestWeekdayTarget) &&
|
|
341
|
-
!schedule.expr.target.direction.nil?
|
|
342
|
-
|
|
343
|
-
current = now
|
|
344
|
-
1000.times do
|
|
345
|
-
candidate = next_expr(schedule.expr, tz, schedule.anchor, current, schedule.during)
|
|
346
|
-
return nil unless candidate
|
|
347
|
-
|
|
348
|
-
c_date = candidate.to_date
|
|
349
|
-
|
|
350
|
-
# Apply until filter
|
|
351
|
-
return nil if until_date && c_date > until_date
|
|
352
|
-
|
|
353
|
-
# Apply during filter
|
|
354
|
-
# Skip this check for expressions that handle during internally (NearestWeekday with direction)
|
|
355
|
-
if has_during && !handles_during_internally && !EvalHelpers.matches_during(c_date, schedule.during)
|
|
356
|
-
skip_to = EvalHelpers.next_during_month(c_date, schedule.during)
|
|
357
|
-
midnight = EvalHelpers.at_time_on_date(skip_to, TimeOfDay.new(0, 0), tz)
|
|
358
|
-
current = midnight - 1
|
|
359
|
-
next
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
# Apply except filter
|
|
363
|
-
if has_exceptions && EvalHelpers.is_excepted(c_date, schedule.except)
|
|
364
|
-
next_day = c_date + 1
|
|
365
|
-
midnight = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
|
|
366
|
-
current = midnight - 1
|
|
367
|
-
next
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
return candidate
|
|
136
|
+
def forward?
|
|
137
|
+
@forward
|
|
371
138
|
end
|
|
372
139
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def self.next_n_from(schedule, now, n)
|
|
377
|
-
results = []
|
|
378
|
-
current = now
|
|
379
|
-
n.times do
|
|
380
|
-
nxt = next_from(schedule, current)
|
|
381
|
-
break unless nxt
|
|
382
|
-
|
|
383
|
-
results << nxt
|
|
384
|
-
current = nxt + 60 # Add 1 minute
|
|
140
|
+
def precedes?(a, b)
|
|
141
|
+
@forward ? a < b : a > b
|
|
385
142
|
end
|
|
386
|
-
results
|
|
387
|
-
end
|
|
388
143
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
anchor_date = schedule.anchor ? Date.parse(schedule.anchor) : nil
|
|
392
|
-
has_exceptions = !schedule.except.empty?
|
|
393
|
-
has_during = !schedule.during.empty?
|
|
394
|
-
|
|
395
|
-
# Handle until clause - if now is after until, search from end of until date
|
|
396
|
-
search_from = now
|
|
397
|
-
if schedule.until
|
|
398
|
-
until_date = EvalHelpers.resolve_until(schedule.until, now)
|
|
399
|
-
now_date = now.to_date
|
|
400
|
-
if now_date > until_date
|
|
401
|
-
next_day = until_date + 1
|
|
402
|
-
search_from = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
|
|
403
|
-
end
|
|
144
|
+
def in_order(items)
|
|
145
|
+
(@forward || items.size < 2) ? items : items.reverse
|
|
404
146
|
end
|
|
405
147
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
148
|
+
FORWARD = new(1)
|
|
149
|
+
BACKWARD = new(-1)
|
|
150
|
+
private_class_method :new
|
|
151
|
+
end
|
|
409
152
|
|
|
410
|
-
|
|
153
|
+
class Search
|
|
154
|
+
attr_reader :zone, :clauses
|
|
411
155
|
|
|
412
|
-
|
|
413
|
-
|
|
156
|
+
def initialize(schedule)
|
|
157
|
+
name = schedule.timezone
|
|
158
|
+
@zone = TZInfo::Timezone.get(name || "UTC")
|
|
159
|
+
@result_zone = name ? @zone : "UTC"
|
|
160
|
+
@clauses = Clauses.new(schedule)
|
|
161
|
+
@cadence = Cadence.of(schedule.expression, @clauses.starting)
|
|
162
|
+
@times = DailyTimes.of(schedule.expression)
|
|
163
|
+
@expr = schedule.expression
|
|
164
|
+
end
|
|
414
165
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
166
|
+
def nearest(now, direction)
|
|
167
|
+
now = @zone.utc_to_local(now.getutc)
|
|
168
|
+
now_date = now.to_date.gregorian
|
|
169
|
+
first_date = @clauses.clamp(now_date, direction)
|
|
170
|
+
# A nearest weekday or a DST shift can move an occurrence out of the period it is
|
|
171
|
+
# scheduled in, so the search starts one period back.
|
|
172
|
+
first_period = @cadence.period_of(first_date) - direction.sign
|
|
173
|
+
farthest = @clauses.farthest_except_date(direction)
|
|
174
|
+
reach = farthest ? @cadence.period_of(farthest) : first_period
|
|
175
|
+
shift = @times.max_shift_days
|
|
176
|
+
best = nil
|
|
177
|
+
each_candidate(first_period, reach, direction) do |candidate|
|
|
178
|
+
date = candidate.date
|
|
179
|
+
break if best && !could_beat?(date, best.landing, direction, shift)
|
|
180
|
+
break if @clauses.ends_search?(date, direction)
|
|
181
|
+
next if behind?(date, now_date, direction, shift) || !@clauses.allows?(candidate)
|
|
423
182
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
search_from = candidate
|
|
427
|
-
next
|
|
183
|
+
occurrence = nearest_on_date(date, now, direction)
|
|
184
|
+
best = occurrence if occurrence && (best.nil? || direction.precedes?(occurrence.instant, best.instant))
|
|
428
185
|
end
|
|
186
|
+
Time.at(best.instant, in: @result_zone) if best && SUPPORTED_RANGE.cover?(best.instant)
|
|
187
|
+
end
|
|
429
188
|
|
|
430
|
-
|
|
431
|
-
if has_during && !EvalHelpers.matches_during(c_date, schedule.during)
|
|
432
|
-
prev_month = EvalHelpers.prev_during_month(c_date, schedule.during)
|
|
433
|
-
return nil unless prev_month
|
|
189
|
+
private
|
|
434
190
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
191
|
+
def each_candidate(first_period, reach, direction, &)
|
|
192
|
+
@cadence.period_starts(first_period, reach, direction) do |start|
|
|
193
|
+
direction.in_order(candidates_in_period(start)).each(&) unless rejects_period?(start)
|
|
438
194
|
end
|
|
439
|
-
|
|
440
|
-
return candidate
|
|
441
195
|
end
|
|
442
196
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
when DayRepeat
|
|
449
|
-
prev_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
450
|
-
when IntervalRepeat
|
|
451
|
-
prev_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
|
|
452
|
-
when WeekRepeat
|
|
453
|
-
prev_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
454
|
-
when MonthRepeat
|
|
455
|
-
prev_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
|
|
456
|
-
when SingleDateExpr
|
|
457
|
-
prev_single_date(expr.date, expr.times, tz, now)
|
|
458
|
-
when YearRepeat
|
|
459
|
-
prev_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
|
|
197
|
+
# A day or month period's candidates all target its own month, so one whose month
|
|
198
|
+
# during rejects holds nothing.
|
|
199
|
+
def rejects_period?(start)
|
|
200
|
+
unit = @cadence.unit
|
|
201
|
+
(unit == :day || unit == :month) && !@clauses.allows_month?(start.month)
|
|
460
202
|
end
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
def self.prev_day_repeat(interval, days, times, tz, anchor, now)
|
|
464
|
-
now_local = tz.utc_to_local(now.utc)
|
|
465
|
-
d = now_local.to_date
|
|
466
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
467
|
-
|
|
468
|
-
1000.times do
|
|
469
|
-
if interval > 1
|
|
470
|
-
offset = EvalHelpers.days_between(anchor_date, d)
|
|
471
|
-
mod = offset % interval
|
|
472
|
-
mod += interval if mod.negative?
|
|
473
|
-
unless mod.zero?
|
|
474
|
-
d -= mod
|
|
475
|
-
next
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
203
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
204
|
+
def candidates_in_period(start)
|
|
205
|
+
case @expr
|
|
206
|
+
when DayRepeat
|
|
207
|
+
Calendar.matches_day_filter?(start, @expr.days) ? [Candidate.on(start)] : []
|
|
208
|
+
when IntervalRepeat
|
|
209
|
+
Calendar.matches_day_filter?(start, @expr.day_filter) ? [Candidate.on(start)] : []
|
|
210
|
+
when WeekRepeat
|
|
211
|
+
@expr.days.map { |day| start + (Weekday.number(day) - 1) }.uniq.sort.map { |date| Candidate.on(date) }
|
|
212
|
+
when MonthRepeat
|
|
213
|
+
Calendar.month_target_dates(start.year, start.month, @expr.target).map { |date| Candidate.new(date, start.month) }
|
|
214
|
+
when YearRepeat
|
|
215
|
+
date = Calendar.year_target_date(start.year, @expr.target)
|
|
216
|
+
date ? [Candidate.on(date)] : []
|
|
217
|
+
when SingleDateExpr
|
|
218
|
+
date = @expr.date.is_a?(IsoDate) ? start : Calendar.date(start.year, MonthName.number(@expr.date.month), @expr.date.day)
|
|
219
|
+
date ? [Candidate.on(date)] : []
|
|
482
220
|
end
|
|
483
|
-
|
|
484
|
-
d -= (interval > 1) ? interval : 1
|
|
485
221
|
end
|
|
486
222
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
now_local = tz.utc_to_local(now.utc)
|
|
492
|
-
d = now_local.to_date
|
|
493
|
-
step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
|
|
494
|
-
from_minutes = (from_time.hour * 60) + from_time.minute
|
|
495
|
-
to_minutes = (to_time.hour * 60) + to_time.minute
|
|
496
|
-
|
|
497
|
-
1000.times do
|
|
498
|
-
if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
|
|
499
|
-
d -= 1
|
|
500
|
-
next
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
# Build list of times in window
|
|
504
|
-
window_times = []
|
|
505
|
-
m = from_minutes
|
|
506
|
-
while m <= to_minutes
|
|
507
|
-
window_times << m
|
|
508
|
-
m += step_minutes
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Search backwards through window
|
|
512
|
-
window_times.reverse_each do |slot|
|
|
513
|
-
h = slot / 60
|
|
514
|
-
min = slot % 60
|
|
515
|
-
candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, min), tz)
|
|
516
|
-
return candidate if candidate && candidate < now
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
d -= 1
|
|
223
|
+
# An occurrence lands from its scheduled date to shift dates after it, on a first pass,
|
|
224
|
+
# and first passes keep wall-clock order.
|
|
225
|
+
def could_beat?(date, landing, direction, shift)
|
|
226
|
+
direction.forward? ? date <= landing : Calendar.days_between(date, landing) <= shift
|
|
520
227
|
end
|
|
521
228
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
now_local = tz.utc_to_local(now.utc)
|
|
528
|
-
d = now_local.to_date
|
|
529
|
-
|
|
530
|
-
# Sort target DOWs in descending order for backwards search
|
|
531
|
-
sorted_days = days.sort_by { |wd| -Weekday.number(wd) }
|
|
532
|
-
|
|
533
|
-
dow_offset = d.cwday - 1
|
|
534
|
-
current_monday = d - dow_offset
|
|
535
|
-
|
|
536
|
-
anchor_dow_offset = anchor_date.cwday - 1
|
|
537
|
-
anchor_monday = anchor_date - anchor_dow_offset
|
|
538
|
-
|
|
539
|
-
54.times do
|
|
540
|
-
weeks = EvalHelpers.weeks_between(anchor_monday, current_monday)
|
|
541
|
-
return nil if weeks.negative?
|
|
542
|
-
|
|
543
|
-
if (weeks % interval).zero?
|
|
544
|
-
sorted_days.each do |wd|
|
|
545
|
-
day_offset = Weekday.number(wd) - 1
|
|
546
|
-
target_date = current_monday + day_offset
|
|
547
|
-
candidate = EvalHelpers.latest_past_at_times(target_date, times, tz, now)
|
|
548
|
-
return candidate if candidate
|
|
549
|
-
end
|
|
229
|
+
def behind?(date, now_date, direction, shift)
|
|
230
|
+
if direction.forward?
|
|
231
|
+
Calendar.days_between(date, now_date) > shift
|
|
232
|
+
else
|
|
233
|
+
Calendar.days_between(now_date, date) > MAX_OVERLAP_DAYS
|
|
550
234
|
end
|
|
551
|
-
|
|
552
|
-
remainder = weeks % interval
|
|
553
|
-
skip_weeks = remainder.zero? ? interval : remainder
|
|
554
|
-
current_monday -= skip_weeks * 7
|
|
555
235
|
end
|
|
556
236
|
|
|
557
|
-
|
|
558
|
-
|
|
237
|
+
def nearest_on_date(date, now, direction)
|
|
238
|
+
case @times
|
|
239
|
+
when DailyTimes::Fixed
|
|
240
|
+
# Every time is compared: one shifted out of a gap can land after a later wall time.
|
|
241
|
+
nearest = landing = nil
|
|
242
|
+
@times.times.each do |time|
|
|
243
|
+
instant, lands_on = WallClock.fixed_time_on(date, time, @zone)
|
|
244
|
+
next if !direction.precedes?(now, instant) || (nearest && !direction.precedes?(instant, nearest))
|
|
559
245
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
year = now_local.year
|
|
563
|
-
month = now_local.month
|
|
564
|
-
|
|
565
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
566
|
-
max_iter = (interval > 1) ? 24 * interval : 24
|
|
567
|
-
|
|
568
|
-
max_iter.times do
|
|
569
|
-
if interval > 1
|
|
570
|
-
cur = Date.new(year, month, 1)
|
|
571
|
-
month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
|
|
572
|
-
if month_offset.negative? || (month_offset % interval) != 0
|
|
573
|
-
month -= 1
|
|
574
|
-
if month < 1
|
|
575
|
-
month = 12
|
|
576
|
-
year -= 1
|
|
577
|
-
end
|
|
578
|
-
next
|
|
246
|
+
nearest = instant
|
|
247
|
+
landing = lands_on
|
|
579
248
|
end
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
nw = EvalHelpers.nearest_weekday(year, month, target.day, target.direction)
|
|
603
|
-
date_candidates << nw if nw
|
|
604
|
-
when OrdinalWeekdayTarget
|
|
605
|
-
ordinal_date = if target.ordinal == OrdinalPosition::LAST
|
|
606
|
-
EvalHelpers.last_weekday_in_month(year, month, target.weekday)
|
|
249
|
+
Occurrence.new(nearest, landing) if nearest
|
|
250
|
+
when DailyTimes::Slots
|
|
251
|
+
instant = nearest_slot(date, now, direction)
|
|
252
|
+
# A slot outside a gap is the first pass of its wall time, so it lands on its date.
|
|
253
|
+
Occurrence.new(instant, date) if instant
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Slot keys never decrease in wall-clock order, so one binary search finds where the
|
|
258
|
+
# slots past now begin.
|
|
259
|
+
def nearest_slot(date, now, direction)
|
|
260
|
+
minutes = @times.minutes
|
|
261
|
+
forward = direction.forward?
|
|
262
|
+
low = 0
|
|
263
|
+
high = minutes.size
|
|
264
|
+
below = above = nil
|
|
265
|
+
while low < high
|
|
266
|
+
middle = (low + high) / 2
|
|
267
|
+
slot = WallClock.slot_on(date, minutes[middle], @zone)
|
|
268
|
+
if forward ? slot.key > now : slot.key >= now
|
|
269
|
+
high = middle
|
|
270
|
+
above = slot
|
|
607
271
|
else
|
|
608
|
-
|
|
272
|
+
low = middle + 1
|
|
273
|
+
below = slot
|
|
609
274
|
end
|
|
610
|
-
date_candidates << ordinal_date if ordinal_date
|
|
611
275
|
end
|
|
276
|
+
index, slot = forward ? [low, above] : [low - 1, below]
|
|
277
|
+
while index >= 0 && index < minutes.size
|
|
278
|
+
slot ||= WallClock.slot_on(date, minutes[index], @zone)
|
|
279
|
+
return slot.instant if slot.instant
|
|
612
280
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
date_candidates.each do |dc|
|
|
617
|
-
candidate = EvalHelpers.latest_past_at_times(dc, times, tz, now)
|
|
618
|
-
return candidate if candidate
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
month -= 1
|
|
622
|
-
if month < 1
|
|
623
|
-
month = 12
|
|
624
|
-
year -= 1
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
nil
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
def self.prev_single_date(date_spec, times, tz, now)
|
|
632
|
-
case date_spec
|
|
633
|
-
when IsoDate
|
|
634
|
-
d = Date.parse(date_spec.date)
|
|
635
|
-
EvalHelpers.latest_past_at_times(d, times, tz, now)
|
|
636
|
-
when NamedDate
|
|
637
|
-
now_local = tz.utc_to_local(now.utc)
|
|
638
|
-
start_year = now_local.year
|
|
639
|
-
8.times do |y|
|
|
640
|
-
year = start_year - y
|
|
641
|
-
begin
|
|
642
|
-
d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
|
|
643
|
-
candidate = EvalHelpers.latest_past_at_times(d, times, tz, now)
|
|
644
|
-
return candidate if candidate
|
|
645
|
-
rescue ArgumentError
|
|
646
|
-
# Invalid date
|
|
647
|
-
end
|
|
281
|
+
slot = nil
|
|
282
|
+
index += direction.sign
|
|
648
283
|
end
|
|
649
284
|
nil
|
|
650
285
|
end
|
|
651
286
|
end
|
|
652
287
|
|
|
653
|
-
|
|
654
|
-
now_local = tz.utc_to_local(now.utc)
|
|
655
|
-
start_year = now_local.year
|
|
656
|
-
anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
|
|
657
|
-
|
|
658
|
-
max_iter = (interval > 1) ? 8 * interval : 8
|
|
288
|
+
Occurrence = Data.define(:instant, :landing)
|
|
659
289
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
target_date = compute_year_target_date(target, year)
|
|
669
|
-
next unless target_date
|
|
670
|
-
|
|
671
|
-
candidate = EvalHelpers.latest_past_at_times(target_date, times, tz, now)
|
|
672
|
-
return candidate if candidate
|
|
290
|
+
# target_month differs from date's month only when a directional nearest weekday crosses
|
|
291
|
+
# into the adjacent month. A Struct, as one is made for every date a search walks and a
|
|
292
|
+
# Data costs twice as much to make.
|
|
293
|
+
Candidate = Struct.new(:date, :target_month) do
|
|
294
|
+
def self.on(date)
|
|
295
|
+
new(date, date.month)
|
|
673
296
|
end
|
|
674
|
-
|
|
675
|
-
nil
|
|
676
297
|
end
|
|
677
298
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
current = from
|
|
683
|
-
loop do
|
|
684
|
-
nxt = next_from(schedule, current)
|
|
685
|
-
break unless nxt
|
|
686
|
-
|
|
687
|
-
yielder << nxt
|
|
688
|
-
current = nxt + 60 # Add 1 minute
|
|
299
|
+
module DailyTimes
|
|
300
|
+
Fixed = Data.define(:times) do
|
|
301
|
+
def max_shift_days
|
|
302
|
+
MAX_SHIFT_DAYS
|
|
689
303
|
end
|
|
690
|
-
end
|
|
691
|
-
end
|
|
692
|
-
|
|
693
|
-
# Returns a lazy Enumerator of occurrences where from < occurrence <= to.
|
|
694
|
-
def self.between(schedule, from, to)
|
|
695
|
-
Enumerator.new do |yielder|
|
|
696
|
-
occurrences(schedule, from).each do |dt|
|
|
697
|
-
break if dt > to
|
|
304
|
+
end
|
|
698
305
|
|
|
699
|
-
|
|
306
|
+
# Each slot is skipped in a gap, so it lands on its own date.
|
|
307
|
+
Slots = Data.define(:minutes) do
|
|
308
|
+
def max_shift_days
|
|
309
|
+
0
|
|
700
310
|
end
|
|
701
|
-
end.lazy
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
def self.matches(schedule, dt)
|
|
705
|
-
tz = TzResolver.resolve(schedule.timezone)
|
|
706
|
-
# Convert to local time in the target timezone
|
|
707
|
-
dt_local = tz.utc_to_local(dt.utc)
|
|
708
|
-
d = dt_local.to_date
|
|
709
|
-
|
|
710
|
-
return false if !schedule.during.empty? && !EvalHelpers.matches_during(d, schedule.during)
|
|
711
|
-
return false if EvalHelpers.is_excepted(d, schedule.except)
|
|
712
|
-
|
|
713
|
-
if schedule.until
|
|
714
|
-
until_date = EvalHelpers.resolve_until(schedule.until, dt)
|
|
715
|
-
return false if d > until_date
|
|
716
311
|
end
|
|
717
312
|
|
|
718
|
-
|
|
719
|
-
|
|
313
|
+
def self.of(expr)
|
|
314
|
+
return Fixed.new(expr.times) unless expr.is_a?(IntervalRepeat)
|
|
720
315
|
|
|
721
|
-
|
|
722
|
-
case expr
|
|
723
|
-
when DayRepeat
|
|
724
|
-
next_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
725
|
-
when IntervalRepeat
|
|
726
|
-
next_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
|
|
727
|
-
when WeekRepeat
|
|
728
|
-
next_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
|
|
729
|
-
when MonthRepeat
|
|
730
|
-
next_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now, during)
|
|
731
|
-
when SingleDateExpr
|
|
732
|
-
next_single_date(expr.date, expr.times, tz, now)
|
|
733
|
-
when YearRepeat
|
|
734
|
-
next_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
|
|
316
|
+
Slots.new(Evaluator.interval_slots(expr.interval, expr.unit, expr.from_time, expr.to_time))
|
|
735
317
|
end
|
|
736
318
|
end
|
|
737
319
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
when DayRepeat
|
|
743
|
-
return false unless EvalHelpers.matches_day_filter(d, expr.days)
|
|
744
|
-
return false unless time_matches.call(expr.times)
|
|
745
|
-
|
|
746
|
-
if expr.interval > 1
|
|
747
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
748
|
-
day_offset = EvalHelpers.days_between(anchor_date, d)
|
|
749
|
-
return day_offset >= 0 && (day_offset % expr.interval).zero?
|
|
750
|
-
end
|
|
751
|
-
true
|
|
752
|
-
|
|
753
|
-
when IntervalRepeat
|
|
754
|
-
return false if expr.day_filter && !EvalHelpers.matches_day_filter(d, expr.day_filter)
|
|
320
|
+
# during applies to a candidate's target month; except, until and starting to its date
|
|
321
|
+
# (spec/README.md, "Nearest weekday and `during`", "The `starting` clause").
|
|
322
|
+
class Clauses
|
|
323
|
+
attr_reader :starting
|
|
755
324
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
diff >= 0 && (diff % step).zero?
|
|
764
|
-
|
|
765
|
-
when WeekRepeat
|
|
766
|
-
dow = d.cwday
|
|
767
|
-
return false unless expr.days.any? { |wd| Weekday.number(wd) == dow }
|
|
768
|
-
return false unless time_matches.call(expr.times)
|
|
769
|
-
|
|
770
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
|
|
771
|
-
weeks = EvalHelpers.weeks_between(anchor_date, d)
|
|
772
|
-
weeks >= 0 && (weeks % expr.interval).zero?
|
|
773
|
-
|
|
774
|
-
when MonthRepeat
|
|
775
|
-
return false unless time_matches.call(expr.times)
|
|
776
|
-
|
|
777
|
-
if expr.interval > 1
|
|
778
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
779
|
-
month_offset = EvalHelpers.months_between_ym(anchor_date, d)
|
|
780
|
-
return false if month_offset.negative? || (month_offset % expr.interval) != 0
|
|
781
|
-
end
|
|
782
|
-
matches_month_target(expr.target, d)
|
|
783
|
-
|
|
784
|
-
when SingleDateExpr
|
|
785
|
-
return false unless time_matches.call(expr.times)
|
|
786
|
-
|
|
787
|
-
matches_date_spec(expr.date, d)
|
|
788
|
-
|
|
789
|
-
when YearRepeat
|
|
790
|
-
return false unless time_matches.call(expr.times)
|
|
791
|
-
|
|
792
|
-
if expr.interval > 1
|
|
793
|
-
anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
|
|
794
|
-
year_offset = d.year - anchor_year
|
|
795
|
-
return false if year_offset.negative? || (year_offset % expr.interval) != 0
|
|
796
|
-
end
|
|
797
|
-
matches_year_target(expr.target, d)
|
|
798
|
-
|
|
799
|
-
else
|
|
800
|
-
false
|
|
325
|
+
def initialize(schedule)
|
|
326
|
+
@starting = schedule.starting && Calendar.parse_date(schedule.starting)
|
|
327
|
+
@until = schedule.until && resolve_until(schedule.until, @starting)
|
|
328
|
+
named, iso = schedule.except.partition { |exception| exception.is_a?(NamedException) }
|
|
329
|
+
@except_month_days = named.map { |exception| [MonthName.number(exception.month), exception.day] }
|
|
330
|
+
@except_dates = iso.map { |exception| Calendar.parse_date(exception.date) }
|
|
331
|
+
@during = schedule.during.map { |month| MonthName.number(month) }
|
|
801
332
|
end
|
|
802
|
-
end
|
|
803
333
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
resolved && resolved.to_i == dt.to_i
|
|
812
|
-
end
|
|
334
|
+
def allows?(candidate)
|
|
335
|
+
date = candidate.date
|
|
336
|
+
allows_month?(candidate.target_month) &&
|
|
337
|
+
@except_month_days.none? { |month, day| date.month == month && date.day == day } &&
|
|
338
|
+
!@except_dates.include?(date) &&
|
|
339
|
+
(@until.nil? || date <= @until) &&
|
|
340
|
+
(@starting.nil? || date >= @starting)
|
|
813
341
|
end
|
|
814
|
-
end
|
|
815
342
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
when DaysTarget
|
|
819
|
-
expanded = Hron.expand_month_target(target)
|
|
820
|
-
expanded.include?(d.day)
|
|
821
|
-
when LastDayTarget
|
|
822
|
-
d == EvalHelpers.last_day_of_month(d.year, d.month)
|
|
823
|
-
when LastWeekdayTarget
|
|
824
|
-
d == EvalHelpers.last_weekday_of_month(d.year, d.month)
|
|
825
|
-
when NearestWeekdayTarget
|
|
826
|
-
target_date = EvalHelpers.nearest_weekday(d.year, d.month, target.day, target.direction)
|
|
827
|
-
target_date && d == target_date
|
|
828
|
-
when OrdinalWeekdayTarget
|
|
829
|
-
ordinal_date = if target.ordinal == OrdinalPosition::LAST
|
|
830
|
-
EvalHelpers.last_weekday_in_month(d.year, d.month, target.weekday)
|
|
831
|
-
else
|
|
832
|
-
EvalHelpers.nth_weekday_of_month(d.year, d.month, target.weekday, OrdinalPosition.to_n(target.ordinal))
|
|
833
|
-
end
|
|
834
|
-
ordinal_date && d == ordinal_date
|
|
835
|
-
else
|
|
836
|
-
false
|
|
343
|
+
def allows_month?(month)
|
|
344
|
+
@during.empty? || @during.include?(month)
|
|
837
345
|
end
|
|
838
|
-
end
|
|
839
346
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
when IsoDate
|
|
843
|
-
d == Date.parse(date_spec.date)
|
|
844
|
-
when NamedDate
|
|
845
|
-
d.month == MonthName.number(date_spec.month) && d.day == date_spec.day
|
|
846
|
-
else
|
|
847
|
-
false
|
|
347
|
+
def end_on(date)
|
|
348
|
+
@until = date if @until.nil? || date < @until
|
|
848
349
|
end
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
def self.matches_year_target(target, d)
|
|
852
|
-
case target
|
|
853
|
-
when YearDateTarget
|
|
854
|
-
d.month == MonthName.number(target.month) && d.day == target.day
|
|
855
|
-
when YearOrdinalWeekdayTarget
|
|
856
|
-
return false if d.month != MonthName.number(target.month)
|
|
857
350
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
OrdinalPosition.to_n(target.ordinal))
|
|
863
|
-
end
|
|
864
|
-
ordinal_date && d == ordinal_date
|
|
865
|
-
when YearDayOfMonthTarget
|
|
866
|
-
d.month == MonthName.number(target.month) && d.day == target.day
|
|
867
|
-
when YearLastWeekdayTarget
|
|
868
|
-
return false if d.month != MonthName.number(target.month)
|
|
869
|
-
|
|
870
|
-
d == EvalHelpers.last_weekday_of_month(d.year, d.month)
|
|
871
|
-
else
|
|
872
|
-
false
|
|
351
|
+
# The one-off except date farthest along direction: the calendar repeats only beyond
|
|
352
|
+
# it (spec/README.md, "Search horizon").
|
|
353
|
+
def farthest_except_date(direction)
|
|
354
|
+
direction.forward? ? @except_dates.max : @except_dates.min
|
|
873
355
|
end
|
|
874
|
-
end
|
|
875
|
-
|
|
876
|
-
# Per-variant next functions
|
|
877
|
-
def self.next_day_repeat(interval, days, times, tz, anchor, now)
|
|
878
|
-
now_local = tz.utc_to_local(now.utc)
|
|
879
|
-
d = now_local.to_date
|
|
880
|
-
|
|
881
|
-
if interval <= 1
|
|
882
|
-
if EvalHelpers.matches_day_filter(d, days)
|
|
883
|
-
candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
884
|
-
return candidate if candidate
|
|
885
|
-
end
|
|
886
356
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
end
|
|
357
|
+
def clamp(date, direction)
|
|
358
|
+
if direction.forward?
|
|
359
|
+
@starting ? [date, @starting].max : date
|
|
360
|
+
else
|
|
361
|
+
@until ? [date, @until].min : date
|
|
893
362
|
end
|
|
894
|
-
|
|
895
|
-
return nil
|
|
896
|
-
end
|
|
897
|
-
|
|
898
|
-
# Interval > 1
|
|
899
|
-
anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
|
|
900
|
-
offset = EvalHelpers.days_between(anchor_date, d)
|
|
901
|
-
remainder = offset % interval
|
|
902
|
-
aligned_date = remainder.zero? ? d : d + (interval - remainder)
|
|
903
|
-
|
|
904
|
-
400.times do
|
|
905
|
-
candidate = EvalHelpers.earliest_future_at_times(aligned_date, times, tz, now)
|
|
906
|
-
return candidate if candidate
|
|
907
|
-
|
|
908
|
-
aligned_date += interval
|
|
909
363
|
end
|
|
910
364
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def self.next_interval_repeat(interval, unit, from_time, to_time, day_filter, tz, now)
|
|
915
|
-
step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
|
|
916
|
-
from_minutes = (from_time.hour * 60) + from_time.minute
|
|
917
|
-
to_minutes = (to_time.hour * 60) + to_time.minute
|
|
918
|
-
|
|
919
|
-
# Convert now to local time in the target timezone
|
|
920
|
-
now_local = tz.utc_to_local(now.utc)
|
|
921
|
-
d = now_local.to_date
|
|
922
|
-
|
|
923
|
-
400.times do
|
|
924
|
-
if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
|
|
925
|
-
d += 1
|
|
926
|
-
next
|
|
927
|
-
end
|
|
928
|
-
|
|
929
|
-
same_day = d == now_local.to_date
|
|
930
|
-
now_minutes = same_day ? (now_local.hour * 60) + now_local.min : -1
|
|
931
|
-
|
|
932
|
-
next_slot = if now_minutes < from_minutes
|
|
933
|
-
from_minutes
|
|
365
|
+
def ends_search?(date, direction)
|
|
366
|
+
if direction.forward?
|
|
367
|
+
!@until.nil? && date > @until
|
|
934
368
|
else
|
|
935
|
-
|
|
936
|
-
from_minutes + (((elapsed / step_minutes) + 1) * step_minutes)
|
|
369
|
+
!@starting.nil? && date < @starting
|
|
937
370
|
end
|
|
371
|
+
end
|
|
938
372
|
|
|
939
|
-
|
|
940
|
-
# This handles DST gaps where intermediate times don't exist
|
|
941
|
-
while next_slot <= to_minutes
|
|
942
|
-
h = next_slot / 60
|
|
943
|
-
m = next_slot % 60
|
|
944
|
-
candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, m), tz)
|
|
945
|
-
return candidate if candidate && candidate > now
|
|
373
|
+
private
|
|
946
374
|
|
|
947
|
-
|
|
948
|
-
|
|
375
|
+
# A named until date is the first such date on or after the starting date, which a
|
|
376
|
+
# named until always has (spec/README.md, "Named `until`"). nil when the date never
|
|
377
|
+
# occurs, so nothing bounds the schedule.
|
|
378
|
+
def resolve_until(until_spec, starting)
|
|
379
|
+
case until_spec
|
|
380
|
+
when IsoUntil then Calendar.parse_date(until_spec.date)
|
|
381
|
+
when NamedUntil
|
|
382
|
+
month = MonthName.number(until_spec.month)
|
|
383
|
+
(0..NAMED_UNTIL_MAX_YEARS)
|
|
384
|
+
.filter_map { |k| Calendar.date(starting.year + k, month, until_spec.day) }
|
|
385
|
+
.find { |date| date >= starting }
|
|
949
386
|
end
|
|
950
|
-
|
|
951
|
-
d += 1
|
|
952
387
|
end
|
|
953
|
-
|
|
954
|
-
nil
|
|
955
388
|
end
|
|
956
389
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
now_local = tz.utc_to_local(now.utc)
|
|
961
|
-
d = now_local.to_date
|
|
962
|
-
sorted_days = days.sort_by { |wd| Weekday.number(wd) }
|
|
390
|
+
class Cadence
|
|
391
|
+
attr_reader :unit
|
|
963
392
|
|
|
964
|
-
|
|
965
|
-
|
|
393
|
+
# Units in 400 years, after which the proleptic Gregorian calendar repeats.
|
|
394
|
+
PER_400_YEARS = {day: 146_097, week: 20_871, month: 4800, year: 400}.freeze
|
|
966
395
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
current_monday = anchor_monday
|
|
977
|
-
next
|
|
396
|
+
def self.of(expr, starting)
|
|
397
|
+
unit, interval, default_anchor = case expr
|
|
398
|
+
in SingleDateExpr[date: IsoDate[date:]] then return new(:day, Calendar.parse_date(date), 1, single: true)
|
|
399
|
+
in SingleDateExpr then [:year, 1, EPOCH_DATE]
|
|
400
|
+
in IntervalRepeat then [:day, 1, EPOCH_DATE]
|
|
401
|
+
in DayRepeat then [:day, expr.interval, EPOCH_DATE]
|
|
402
|
+
in WeekRepeat then [:week, expr.interval, EPOCH_MONDAY]
|
|
403
|
+
in MonthRepeat then [:month, expr.interval, EPOCH_DATE]
|
|
404
|
+
in YearRepeat then [:year, expr.interval, EPOCH_DATE]
|
|
978
405
|
end
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
return candidate if candidate
|
|
986
|
-
end
|
|
406
|
+
anchor = starting || default_anchor
|
|
407
|
+
origin = case unit
|
|
408
|
+
when :day then anchor
|
|
409
|
+
when :week then Calendar.monday_of_week(anchor)
|
|
410
|
+
when :month then Calendar.date(anchor.year, anchor.month, 1)
|
|
411
|
+
when :year then Calendar.first_of_year(anchor.year)
|
|
987
412
|
end
|
|
988
|
-
|
|
989
|
-
remainder = weeks % interval
|
|
990
|
-
skip_weeks = remainder.zero? ? interval : interval - remainder
|
|
991
|
-
current_monday += skip_weeks * 7
|
|
413
|
+
new(unit, origin, interval)
|
|
992
414
|
end
|
|
993
415
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
# For NearestWeekday with direction, we need to apply the during filter here
|
|
1006
|
-
# because the result can cross month boundaries
|
|
1007
|
-
apply_during_filter = !during.empty? &&
|
|
1008
|
-
target.is_a?(NearestWeekdayTarget) &&
|
|
1009
|
-
!target.direction.nil?
|
|
1010
|
-
|
|
1011
|
-
max_iter.times do
|
|
1012
|
-
# Check during filter for NearestWeekday with direction
|
|
1013
|
-
if apply_during_filter && !during.any? { |mn| MonthName.number(mn) == month }
|
|
1014
|
-
month += 1
|
|
1015
|
-
if month > 12
|
|
1016
|
-
month = 1
|
|
1017
|
-
year += 1
|
|
1018
|
-
end
|
|
1019
|
-
next
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
if interval > 1
|
|
1023
|
-
cur = Date.new(year, month, 1)
|
|
1024
|
-
month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
|
|
1025
|
-
if month_offset.negative? || (month_offset % interval) != 0
|
|
1026
|
-
month += 1
|
|
1027
|
-
if month > 12
|
|
1028
|
-
month = 1
|
|
1029
|
-
year += 1
|
|
1030
|
-
end
|
|
1031
|
-
next
|
|
1032
|
-
end
|
|
1033
|
-
end
|
|
1034
|
-
|
|
1035
|
-
date_candidates = []
|
|
1036
|
-
|
|
1037
|
-
case target
|
|
1038
|
-
when DaysTarget
|
|
1039
|
-
expanded = Hron.expand_month_target(target)
|
|
1040
|
-
last = EvalHelpers.last_day_of_month(year, month)
|
|
1041
|
-
expanded.each do |day_num|
|
|
1042
|
-
next unless day_num <= last.day
|
|
1043
|
-
|
|
1044
|
-
begin
|
|
1045
|
-
date_candidates << Date.new(year, month, day_num)
|
|
1046
|
-
rescue ArgumentError
|
|
1047
|
-
# Invalid date
|
|
1048
|
-
end
|
|
1049
|
-
end
|
|
1050
|
-
when LastDayTarget
|
|
1051
|
-
date_candidates << EvalHelpers.last_day_of_month(year, month)
|
|
1052
|
-
when LastWeekdayTarget
|
|
1053
|
-
date_candidates << EvalHelpers.last_weekday_of_month(year, month)
|
|
1054
|
-
when NearestWeekdayTarget
|
|
1055
|
-
nw = EvalHelpers.nearest_weekday(year, month, target.day, target.direction)
|
|
1056
|
-
date_candidates << nw if nw
|
|
1057
|
-
when OrdinalWeekdayTarget
|
|
1058
|
-
ordinal_date = if target.ordinal == OrdinalPosition::LAST
|
|
1059
|
-
EvalHelpers.last_weekday_in_month(year, month, target.weekday)
|
|
1060
|
-
else
|
|
1061
|
-
EvalHelpers.nth_weekday_of_month(year, month, target.weekday, OrdinalPosition.to_n(target.ordinal))
|
|
1062
|
-
end
|
|
1063
|
-
date_candidates << ordinal_date if ordinal_date
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
best = nil
|
|
1067
|
-
date_candidates.each do |dc|
|
|
1068
|
-
candidate = EvalHelpers.earliest_future_at_times(dc, times, tz, now)
|
|
1069
|
-
best = candidate if candidate && (best.nil? || candidate < best)
|
|
1070
|
-
end
|
|
1071
|
-
return best if best
|
|
416
|
+
def initialize(unit, origin, interval, single: false)
|
|
417
|
+
@unit = unit
|
|
418
|
+
@origin = origin
|
|
419
|
+
@interval = interval
|
|
420
|
+
@single = single
|
|
421
|
+
@origin_month = Calendar.month_index(origin)
|
|
422
|
+
# A nearest weekday can move a candidate into the calendar from the period on
|
|
423
|
+
# either side of it.
|
|
424
|
+
@earliest = period_of(Calendar::FIRST_DATE) - 1
|
|
425
|
+
@latest = period_of(Calendar::LAST_DATE) + 1
|
|
426
|
+
end
|
|
1072
427
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
428
|
+
def period_of(date)
|
|
429
|
+
case @unit
|
|
430
|
+
when :day then Calendar.days_between(@origin, date)
|
|
431
|
+
when :week then Calendar.days_between(@origin, date).div(7)
|
|
432
|
+
when :month then Calendar.month_index(date) - @origin_month
|
|
433
|
+
when :year then date.year - @origin.year
|
|
1077
434
|
end
|
|
1078
435
|
end
|
|
1079
436
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
d = Date.parse(date_spec.date)
|
|
1087
|
-
EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
1088
|
-
when NamedDate
|
|
1089
|
-
now_local = tz.utc_to_local(now.utc)
|
|
1090
|
-
start_year = now_local.year
|
|
1091
|
-
8.times do |y|
|
|
1092
|
-
year = start_year + y
|
|
1093
|
-
begin
|
|
1094
|
-
d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
|
|
1095
|
-
candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
|
|
1096
|
-
return candidate if candidate
|
|
1097
|
-
rescue ArgumentError
|
|
1098
|
-
# Invalid date
|
|
1099
|
-
end
|
|
437
|
+
def start_of(k)
|
|
438
|
+
case @unit
|
|
439
|
+
when :day then @origin + k
|
|
440
|
+
when :week then @origin + (k * 7)
|
|
441
|
+
when :month then Calendar.first_of_month(@origin_month + k)
|
|
442
|
+
when :year then Calendar.first_of_year(@origin.year + k)
|
|
1100
443
|
end
|
|
1101
|
-
nil
|
|
1102
444
|
end
|
|
1103
|
-
end
|
|
1104
445
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
446
|
+
# Walks one search horizon beyond whichever of first_period and reach is farther along
|
|
447
|
+
# direction (spec/README.md, "Search horizon").
|
|
448
|
+
def period_starts(first_period, reach, direction)
|
|
449
|
+
return yield @origin if @single
|
|
1109
450
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
451
|
+
first = align(first_period, direction)
|
|
452
|
+
beyond = direction.sign * (align(reach, direction) - first)
|
|
453
|
+
count = horizon_periods + HORIZON_MARGIN_PERIODS + ([beyond, 0].max / @interval)
|
|
454
|
+
# A walk starts inside the calendar, at now or a starting date, and ends at its edge.
|
|
455
|
+
far = direction.sign * ((direction.forward? ? @latest : @earliest) - first)
|
|
456
|
+
inside = 0...[(far / @interval) + 1, count].min
|
|
457
|
+
step = direction.sign * @interval
|
|
458
|
+
inside.each { |i| yield start_of(first + (i * step)) }
|
|
459
|
+
end
|
|
1119
460
|
|
|
1120
|
-
|
|
1121
|
-
next unless target_date
|
|
461
|
+
private
|
|
1122
462
|
|
|
1123
|
-
|
|
1124
|
-
|
|
463
|
+
def align(k, direction)
|
|
464
|
+
direction.forward? ? k + (-k % @interval) : k - (k % @interval)
|
|
1125
465
|
end
|
|
1126
466
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
when YearDateTarget
|
|
1133
|
-
Date.new(year, MonthName.number(target.month), target.day)
|
|
1134
|
-
when YearOrdinalWeekdayTarget
|
|
1135
|
-
if target.ordinal == OrdinalPosition::LAST
|
|
1136
|
-
EvalHelpers.last_weekday_in_month(year, MonthName.number(target.month), target.weekday)
|
|
1137
|
-
else
|
|
1138
|
-
EvalHelpers.nth_weekday_of_month(year, MonthName.number(target.month), target.weekday,
|
|
1139
|
-
OrdinalPosition.to_n(target.ordinal))
|
|
1140
|
-
end
|
|
1141
|
-
when YearDayOfMonthTarget
|
|
1142
|
-
Date.new(year, MonthName.number(target.month), target.day)
|
|
1143
|
-
when YearLastWeekdayTarget
|
|
1144
|
-
EvalHelpers.last_weekday_of_month(year, MonthName.number(target.month))
|
|
467
|
+
# Aligned periods in lcm(400 years, interval units), after which both the calendar and
|
|
468
|
+
# the alignment repeat.
|
|
469
|
+
def horizon_periods
|
|
470
|
+
cycle = PER_400_YEARS.fetch(@unit)
|
|
471
|
+
cycle / cycle.gcd(@interval)
|
|
1145
472
|
end
|
|
1146
|
-
rescue ArgumentError
|
|
1147
|
-
nil
|
|
1148
473
|
end
|
|
474
|
+
|
|
475
|
+
private_constant :EPOCH_MONDAY, :EPOCH_DATE, :SUPPORTED_RANGE, :HORIZON_MARGIN_PERIODS, :MAX_SHIFT_DAYS, :NAMED_UNTIL_MAX_YEARS,
|
|
476
|
+
:Direction, :Search, :Occurrence, :Candidate, :DailyTimes, :Clauses, :Cadence,
|
|
477
|
+
:Calendar, :WallClock
|
|
1149
478
|
end
|
|
1150
479
|
end
|