cyclical 0.1.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.
- data/.gitignore +19 -0
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +85 -0
- data/Rakefile +1 -0
- data/cyclical.gemspec +24 -0
- data/lib/cyclical/filters/monthdays_filter.rb +38 -0
- data/lib/cyclical/filters/months_filter.rb +55 -0
- data/lib/cyclical/filters/weekdays_filter.rb +90 -0
- data/lib/cyclical/filters/yeardays_filter.rb +38 -0
- data/lib/cyclical/occurrence.rb +121 -0
- data/lib/cyclical/rule.rb +235 -0
- data/lib/cyclical/rules/daily_rule.rb +49 -0
- data/lib/cyclical/rules/monthly_rule.rb +56 -0
- data/lib/cyclical/rules/weekly_rule.rb +58 -0
- data/lib/cyclical/rules/yearly_rule.rb +66 -0
- data/lib/cyclical/schedule.rb +125 -0
- data/lib/cyclical/suboccurrence.rb +48 -0
- data/lib/cyclical/version.rb +3 -0
- data/lib/cyclical.rb +12 -0
- data/spec/filters/monthdays_filter_spec.rb +36 -0
- data/spec/filters/months_filter_spec.rb +19 -0
- data/spec/filters/weekdays_filter_spec.rb +108 -0
- data/spec/filters/yeardays_filter_spec.rb +36 -0
- data/spec/occurrence_spec.rb +207 -0
- data/spec/rule_dsl_spec.rb +105 -0
- data/spec/rules/daily_rule_spec.rb +140 -0
- data/spec/rules/monthly_rule_spec.rb +218 -0
- data/spec/rules/weekly_rule_spec.rb +150 -0
- data/spec/rules/yearly_rule_spec.rb +232 -0
- data/spec/schedule_spec.rb +650 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/suboccurrence_spec.rb +51 -0
- metadata +126 -0
@@ -0,0 +1,235 @@
|
|
1
|
+
require 'cyclical/filters/months_filter'
|
2
|
+
require 'cyclical/filters/weekdays_filter'
|
3
|
+
require 'cyclical/filters/monthdays_filter'
|
4
|
+
require 'cyclical/filters/yeardays_filter'
|
5
|
+
|
6
|
+
module Cyclical
|
7
|
+
|
8
|
+
# Rules describe the basic recurrence patterns (frequency and interval) and hold the set of rules (called filters)
|
9
|
+
# that a candidate date must match to be included into the recurrence set.
|
10
|
+
# Rules can align a date to a closest date (in the past or in the future) matching all the filters with respect to
|
11
|
+
# selected start date of the recurrence.
|
12
|
+
class Rule
|
13
|
+
|
14
|
+
attr_reader :interval
|
15
|
+
|
16
|
+
def initialize(interval = 1)
|
17
|
+
@interval = interval
|
18
|
+
@filters = []
|
19
|
+
@filter_map = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
# rule specification DSL
|
23
|
+
|
24
|
+
def count(n = nil)
|
25
|
+
return @count unless n
|
26
|
+
|
27
|
+
@count = n
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
def stop(t = nil)
|
32
|
+
return @stop unless t
|
33
|
+
|
34
|
+
@stop = t
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
def months(*months)
|
39
|
+
raise RuntimeError, "Months filter already set" if @filter_map[:month]
|
40
|
+
|
41
|
+
f = MonthsFilter.new(*months)
|
42
|
+
@filters << f
|
43
|
+
@filter_map[:months] = f
|
44
|
+
|
45
|
+
self
|
46
|
+
end
|
47
|
+
alias :month :months
|
48
|
+
|
49
|
+
def weekdays(*weekdays)
|
50
|
+
raise RuntimeError, "weekdays filter already set" if @filter_map[:weekdays]
|
51
|
+
weekdays = [self] + weekdays
|
52
|
+
|
53
|
+
f = WeekdaysFilter.new(*weekdays)
|
54
|
+
@filters << f
|
55
|
+
@filter_map[:weekdays] = f
|
56
|
+
|
57
|
+
self
|
58
|
+
end
|
59
|
+
alias :weekday :weekdays
|
60
|
+
|
61
|
+
def monthdays(*monthdays)
|
62
|
+
raise RuntimeError, "monthdays filter already set" if @filter_map[:monthdays]
|
63
|
+
|
64
|
+
f = MonthdaysFilter.new(*monthdays)
|
65
|
+
@filters << f
|
66
|
+
@filter_map[:monthdays] = f
|
67
|
+
|
68
|
+
self
|
69
|
+
end
|
70
|
+
alias :monthday :monthdays
|
71
|
+
|
72
|
+
def yeardays(*yeardays)
|
73
|
+
raise RuntimeError, "yeardays filter already set" if @filter_map[:yeardays]
|
74
|
+
|
75
|
+
f = YeardaysFilter.new(*yeardays)
|
76
|
+
@filters << f
|
77
|
+
@filter_map[:yeardays] = f
|
78
|
+
|
79
|
+
self
|
80
|
+
end
|
81
|
+
alias :yearday :yeardays
|
82
|
+
|
83
|
+
|
84
|
+
def filters(kind = nil)
|
85
|
+
return @filters if kind.nil?
|
86
|
+
|
87
|
+
@filter_map[kind.to_sym]
|
88
|
+
end
|
89
|
+
|
90
|
+
# rule API
|
91
|
+
|
92
|
+
def finite?
|
93
|
+
!infinite?
|
94
|
+
end
|
95
|
+
|
96
|
+
def infinite?
|
97
|
+
@count.nil? && @stop.nil?
|
98
|
+
end
|
99
|
+
|
100
|
+
# returns true if time is aligned to the recurrence pattern and matches all the filters
|
101
|
+
def match?(time, base)
|
102
|
+
aligned?(time, base) && @filters.all? { |f| f.match?(time) }
|
103
|
+
end
|
104
|
+
|
105
|
+
# get next date matching the rule (not checking limits). Returns next occurrence even if +time+ matches the rule.
|
106
|
+
def next(time, base)
|
107
|
+
current = time
|
108
|
+
until match?(current, base) && current > time
|
109
|
+
pot_next = align(potential_next(current, base), base)
|
110
|
+
pot_next += min_step if pot_next == current
|
111
|
+
|
112
|
+
current = pot_next
|
113
|
+
end
|
114
|
+
|
115
|
+
current
|
116
|
+
end
|
117
|
+
|
118
|
+
# get previous date matching the rule (not checking limits). Returns next occurrence even if +time+ matches the rule.
|
119
|
+
def previous(time, base)
|
120
|
+
current = time
|
121
|
+
until match?(current, base) && current < time
|
122
|
+
pot_prev = align(potential_previous(current, base), base)
|
123
|
+
pot_prev -= min_step if pot_prev == current
|
124
|
+
|
125
|
+
current = pot_prev
|
126
|
+
end
|
127
|
+
|
128
|
+
current
|
129
|
+
end
|
130
|
+
|
131
|
+
# basic building blocks of the computations
|
132
|
+
|
133
|
+
def aligned?(time, base)
|
134
|
+
# for subclass to override
|
135
|
+
end
|
136
|
+
|
137
|
+
def step
|
138
|
+
# for subclass to override
|
139
|
+
end
|
140
|
+
|
141
|
+
def to_hash
|
142
|
+
hash = { :freq => self.class.to_s.underscore.split('/').last.split('_').first, :interval => @interval }
|
143
|
+
|
144
|
+
hash[:count] = @count if @count
|
145
|
+
hash[:stop] = @stop if @stop
|
146
|
+
|
147
|
+
hash[:weekdays] = (filters(:weekdays).weekdays + [filters(:weekdays).ordered_weekdays]) if filters(:weekdays)
|
148
|
+
hash[:monthdays] = filters(:monthdays).monthdays if filters(:monthdays)
|
149
|
+
hash[:yeardays] = filters(:yeardays).yeardays if filters(:yeardays)
|
150
|
+
hash[:months] = filters(:months).months if filters(:months)
|
151
|
+
|
152
|
+
hash
|
153
|
+
end
|
154
|
+
|
155
|
+
def to_json
|
156
|
+
to_hash.to_json
|
157
|
+
end
|
158
|
+
|
159
|
+
# factory methods
|
160
|
+
|
161
|
+
class << self
|
162
|
+
def daily(interval = 1)
|
163
|
+
DailyRule.new(interval)
|
164
|
+
end
|
165
|
+
|
166
|
+
def yearly(interval = 1)
|
167
|
+
YearlyRule.new(interval)
|
168
|
+
end
|
169
|
+
|
170
|
+
def weekly(interval = 1)
|
171
|
+
WeeklyRule.new(interval)
|
172
|
+
end
|
173
|
+
|
174
|
+
def monthly(interval = 1)
|
175
|
+
MonthlyRule.new(interval)
|
176
|
+
end
|
177
|
+
|
178
|
+
def from_hash(hash)
|
179
|
+
raise "Bad Hash format: '#{hash.inspect}'" unless hash[:freq] && hash[:interval]
|
180
|
+
|
181
|
+
rule = self.send(hash[:freq].to_sym, hash[:interval].to_i)
|
182
|
+
|
183
|
+
rule.count(hash[:count]) if hash.has_key?(:count)
|
184
|
+
rule.stop(hash[:stop]) if hash.has_key?(:stop)
|
185
|
+
|
186
|
+
rule.weekdays(*hash[:weekdays]) if hash.has_key?(:weekdays)
|
187
|
+
rule.monthdays(*hash[:monthdays]) if hash.has_key?(:monthdays)
|
188
|
+
rule.yeardays(*hash[:yeardays]) if hash.has_key?(:yeardays)
|
189
|
+
rule.months(*hash[:months]) if hash.has_key?(:months)
|
190
|
+
|
191
|
+
rule
|
192
|
+
end
|
193
|
+
|
194
|
+
def from_json(json)
|
195
|
+
h = JSON.parse(json)
|
196
|
+
h['stop'] = Time.parse(h['stop']) if h['stop']
|
197
|
+
|
198
|
+
from_hash(h.symbolize_keys)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
protected
|
203
|
+
|
204
|
+
# Next comes the heart of all the calculations
|
205
|
+
|
206
|
+
# Find a potential next date matching the rule as a maximum of next
|
207
|
+
# valid dates from all the filters. Subclasses should add a check of
|
208
|
+
# recurrence pattern match
|
209
|
+
def potential_next(current, base)
|
210
|
+
@filters.map { |f| f.next(current) }.max || current
|
211
|
+
end
|
212
|
+
|
213
|
+
# Find a potential previous date matching the rule as a minimum of previous
|
214
|
+
# valid dates from all the filters. Subclasses should add a check of
|
215
|
+
# recurrence pattern match
|
216
|
+
def potential_previous(current, base)
|
217
|
+
@filters.map { |f| f.previous(current) }.min || current
|
218
|
+
end
|
219
|
+
|
220
|
+
# Should return a time aligned to the base in the rule interval resolution, e.g.:
|
221
|
+
# - in a daily rule a time on the same day with a correct hour, minute and second
|
222
|
+
# - in a weekly rule a time in the same week with a correct weekday, hour, minute and second
|
223
|
+
def align(time, base)
|
224
|
+
raise NotImplementedError, "#{self.class}.align should be overriden and return a time in the period of time parameter, aligned to base"
|
225
|
+
end
|
226
|
+
|
227
|
+
# Minimal step of all the filters and the recurrence rule. This allows the
|
228
|
+
# next/previous calculation to move a sane amount of time forward when all
|
229
|
+
# the filters and the rule match but the candidate is before/after the
|
230
|
+
# requested time (which is caused by date alignment)
|
231
|
+
def min_step
|
232
|
+
@min_step ||= ([step] + @filters.map { |f| f.step }).min
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'cyclical/rule'
|
2
|
+
|
3
|
+
module Cyclical
|
4
|
+
# holds daily rule configuration
|
5
|
+
class DailyRule < Rule
|
6
|
+
|
7
|
+
def aligned?(time, base)
|
8
|
+
return false unless (base.to_date - time.to_date) % @interval == 0
|
9
|
+
return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec]
|
10
|
+
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
def step
|
15
|
+
@interval.days
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def potential_next(current, base)
|
21
|
+
candidate = super(current, base)
|
22
|
+
|
23
|
+
rem = (base.to_date - candidate.to_date) % @interval
|
24
|
+
|
25
|
+
return candidate if rem == 0
|
26
|
+
|
27
|
+
rem += @interval if rem < 0
|
28
|
+
candidate.beginning_of_day + rem.days
|
29
|
+
end
|
30
|
+
|
31
|
+
def potential_previous(current, base)
|
32
|
+
candidate = super(current, base)
|
33
|
+
|
34
|
+
rem = (base.to_date - candidate.to_date) % @interval
|
35
|
+
|
36
|
+
return candidate if rem == 0
|
37
|
+
|
38
|
+
rem += @interval if rem < 0
|
39
|
+
candidate.beginning_of_day + (rem - @interval).days
|
40
|
+
end
|
41
|
+
|
42
|
+
def align(time, base)
|
43
|
+
# compensate crossing DST barrier (oh my...)
|
44
|
+
offset = time.beginning_of_day.utc_offset
|
45
|
+
time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
|
46
|
+
time += (offset - time.utc_offset)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'cyclical/rule'
|
2
|
+
|
3
|
+
module Cyclical
|
4
|
+
# holds weekly rule configuration
|
5
|
+
class MonthlyRule < Rule
|
6
|
+
|
7
|
+
# check if time is aligned to a base time, including interval check
|
8
|
+
def aligned?(time, base)
|
9
|
+
return false unless ((12 * base.year + base.mon) - (12 * time.year + time.mon)) % @interval == 0
|
10
|
+
return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
|
11
|
+
return false unless base.day == time.day || monthday_filters
|
12
|
+
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
# default step of the rule
|
17
|
+
def step
|
18
|
+
@interval.months
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
def potential_next(current, base)
|
24
|
+
candidate = super(current, base)
|
25
|
+
|
26
|
+
rem = ((12 * base.year + base.mon) - (12 * candidate.year + candidate.mon)) % @interval
|
27
|
+
return candidate if rem == 0
|
28
|
+
|
29
|
+
(candidate + rem.months).beginning_of_month
|
30
|
+
end
|
31
|
+
|
32
|
+
def potential_previous(current, base)
|
33
|
+
candidate = super(current, base)
|
34
|
+
|
35
|
+
rem = ((12 * base.year + base.mon) - (12 * candidate.year + candidate.mon)) % @interval
|
36
|
+
return candidate if rem == 0
|
37
|
+
|
38
|
+
(candidate + rem.months - step).end_of_month
|
39
|
+
end
|
40
|
+
|
41
|
+
def align(time, base)
|
42
|
+
time = time.beginning_of_month + (base.day - 1).days unless time.day == base.day || monthday_filters
|
43
|
+
|
44
|
+
# compensate crossing DST barrier (oh my...)
|
45
|
+
offset = time.beginning_of_day.utc_offset
|
46
|
+
time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
|
47
|
+
time += (offset - time.utc_offset)
|
48
|
+
|
49
|
+
time
|
50
|
+
end
|
51
|
+
|
52
|
+
def monthday_filters
|
53
|
+
filters(:weekdays) || filters(:monthdays) || filters(:yeardays) || filters(:weeks) || filters(:months)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'cyclical/rule'
|
2
|
+
|
3
|
+
module Cyclical
|
4
|
+
# holds daily rule configuration
|
5
|
+
class WeeklyRule < Rule
|
6
|
+
|
7
|
+
# check if time is aligned to a base time, including interval check
|
8
|
+
def aligned?(time, base)
|
9
|
+
return false unless ((base.beginning_of_week - time.beginning_of_week) / 604800).to_i % @interval == 0 # 604800 = 7.days
|
10
|
+
return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
|
11
|
+
|
12
|
+
return false unless base.wday == time.wday || weekday_filters
|
13
|
+
|
14
|
+
# wow, passed every test
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
# default step of the rule
|
19
|
+
def step
|
20
|
+
@interval.weeks
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
def potential_next(current, base)
|
26
|
+
candidate = super(current, base)
|
27
|
+
|
28
|
+
rem = ((base.beginning_of_week - candidate.beginning_of_week) / 604800).to_i % @interval
|
29
|
+
return candidate if rem == 0
|
30
|
+
|
31
|
+
(candidate + rem.weeks).beginning_of_week
|
32
|
+
end
|
33
|
+
|
34
|
+
def potential_previous(current, base)
|
35
|
+
candidate = super(current, base)
|
36
|
+
|
37
|
+
rem = ((base.beginning_of_week - candidate.beginning_of_week) / 604800).to_i % @interval
|
38
|
+
return candidate if rem == 0
|
39
|
+
|
40
|
+
(candidate + rem.weeks - step).end_of_week
|
41
|
+
end
|
42
|
+
|
43
|
+
def align(time, base)
|
44
|
+
time = time.beginning_of_week + base.wday.days unless time.wday == base.wday || weekday_filters
|
45
|
+
|
46
|
+
# compensate crossing DST barrier (oh my...)
|
47
|
+
offset = time.beginning_of_day.utc_offset
|
48
|
+
time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
|
49
|
+
time += (offset - time.utc_offset)
|
50
|
+
|
51
|
+
time
|
52
|
+
end
|
53
|
+
|
54
|
+
def weekday_filters
|
55
|
+
filters(:weekdays) || filters(:monthdays) || filters(:yeardays) || filters(:yeardays) || filters(:weeks) || filters(:months)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'cyclical/rule'
|
2
|
+
|
3
|
+
module Cyclical
|
4
|
+
# holds daily rule configuration
|
5
|
+
class YearlyRule < Rule
|
6
|
+
|
7
|
+
# check if time is aligned to a base time, including interval check
|
8
|
+
def aligned?(time, base)
|
9
|
+
return false unless (base.year - time.year).to_i % @interval == 0
|
10
|
+
return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
|
11
|
+
return false unless time.day == base.day || filters(:weekdays) || filters(:monthdays) || filters(:yeardays)
|
12
|
+
return false unless time.month == base.month || filters(:yeardays) || filters(:weeks) || filters(:months)
|
13
|
+
|
14
|
+
# wow, passed every test
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
# default step of the rule
|
19
|
+
def step
|
20
|
+
@interval.years
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# closest valid date
|
26
|
+
def potential_next(current, base)
|
27
|
+
candidate = super(current, base)
|
28
|
+
return candidate if (base.year - candidate.year).to_i % @interval == 0
|
29
|
+
|
30
|
+
years = ((base.year - candidate.year).to_i % @interval)
|
31
|
+
|
32
|
+
(candidate + years.years).beginning_of_year
|
33
|
+
end
|
34
|
+
|
35
|
+
def potential_previous(current, base)
|
36
|
+
candidate = super(current, base)
|
37
|
+
return candidate if (base.year - candidate.year).to_i % @interval == 0
|
38
|
+
|
39
|
+
years = ((base.year - candidate.year).to_i % @interval)
|
40
|
+
|
41
|
+
(candidate + (years - @interval).years).end_of_year
|
42
|
+
end
|
43
|
+
|
44
|
+
def align(time, base)
|
45
|
+
day = (day_filters ? time.day : base.day)
|
46
|
+
mon = (month_filters ? time.mon : base.mon)
|
47
|
+
|
48
|
+
time = time.beginning_of_year + (mon - 1).months + (day - 1).days
|
49
|
+
|
50
|
+
# compensate crossing DST barrier (oh my...)
|
51
|
+
offset = time.beginning_of_day.utc_offset
|
52
|
+
time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
|
53
|
+
time += (offset - time.utc_offset)
|
54
|
+
|
55
|
+
time
|
56
|
+
end
|
57
|
+
|
58
|
+
def day_filters
|
59
|
+
filters(:weekdays) || filters(:monthdays) || filters(:yeardays)
|
60
|
+
end
|
61
|
+
|
62
|
+
def month_filters
|
63
|
+
filters(:weekdays) || filters(:yeardays) || filters(:weeks) || filters(:months)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
require 'cyclical/rule'
|
4
|
+
|
5
|
+
require 'cyclical/rules/daily_rule'
|
6
|
+
require 'cyclical/rules/weekly_rule'
|
7
|
+
require 'cyclical/rules/monthly_rule'
|
8
|
+
require 'cyclical/rules/yearly_rule'
|
9
|
+
|
10
|
+
require 'cyclical/occurrence'
|
11
|
+
|
12
|
+
module Cyclical
|
13
|
+
class Schedule
|
14
|
+
|
15
|
+
attr_reader :start_time
|
16
|
+
attr_accessor :end_time
|
17
|
+
|
18
|
+
def initialize(start_time, rule = nil)
|
19
|
+
@occurrence = Occurrence.new rule, start_time unless rule.nil?
|
20
|
+
@start_time = @occurrence ? @occurrence.start_time : start_time
|
21
|
+
end
|
22
|
+
|
23
|
+
def rule=(rule)
|
24
|
+
@occurrence = (rule.nil? ? nil : Occurrence.new(rule, start_time))
|
25
|
+
@occurrence.duration = end_time ? (end_time - start_time) : 0
|
26
|
+
end
|
27
|
+
|
28
|
+
def rule
|
29
|
+
@occurrence.nil? ? nil : @occurrence.rule
|
30
|
+
end
|
31
|
+
|
32
|
+
def end_time=(time)
|
33
|
+
raise "End time is before start time" if time < @start_time
|
34
|
+
@end_time = time
|
35
|
+
@occurrence.duration = (time - start_time) unless @occurrence.nil?
|
36
|
+
|
37
|
+
time
|
38
|
+
end
|
39
|
+
|
40
|
+
# query interface
|
41
|
+
|
42
|
+
def first(n)
|
43
|
+
return [start_time] if @occurrence.nil?
|
44
|
+
|
45
|
+
@occurrence.next_occurrences(n, start_time)
|
46
|
+
end
|
47
|
+
|
48
|
+
# first occurrence in [time, infinity)
|
49
|
+
def next_occurrence(time)
|
50
|
+
return (start_time < time ? nil : start_time) if @occurrence.nil?
|
51
|
+
|
52
|
+
@occurrence.next_occurrence(time)
|
53
|
+
end
|
54
|
+
|
55
|
+
# last occurrence in (-infinity, time)
|
56
|
+
def previous_occurrence(time)
|
57
|
+
return (start_time >= time ? nil : start_time) if @occurrence.nil?
|
58
|
+
|
59
|
+
@occurrence.previous_occurrence(time)
|
60
|
+
end
|
61
|
+
|
62
|
+
def occurrences(end_time = nil)
|
63
|
+
raise ArgumentError, "You have to specify end time for an infinite schedule occurrence listing" if end_time.nil? && @occurrence && @occurrence.rule.infinite?
|
64
|
+
|
65
|
+
if end_time
|
66
|
+
occurrences_between(start_time, end_time)
|
67
|
+
else
|
68
|
+
return [start_time] if @occurrence.nil?
|
69
|
+
|
70
|
+
@occurrence.all
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# occurrences in [t1, t2)
|
75
|
+
def occurrences_between(t1, t2)
|
76
|
+
return ((start_time < t1 || @start_time >= t2) ? [] : [start_time]) if @occurrence.nil?
|
77
|
+
|
78
|
+
@occurrence.occurrences_between(t1, t2)
|
79
|
+
end
|
80
|
+
|
81
|
+
def suboccurrences_between(t1, t2)
|
82
|
+
raise RuntimeError, "Schedule must have an end time to compute suboccurrences" unless end_time
|
83
|
+
|
84
|
+
return [Suboccurrence.find(:occurrence => start_time..end_time, :interval => t1..t2)] if @occurrence.nil?
|
85
|
+
|
86
|
+
@occurrence.suboccurrences_between(t1, t2)
|
87
|
+
end
|
88
|
+
|
89
|
+
def to_hash
|
90
|
+
hash = @occurrence.nil? ? {} : @occurrence.to_hash.clone
|
91
|
+
|
92
|
+
hash[:start] = start_time
|
93
|
+
hash[:end] = end_time if end_time
|
94
|
+
|
95
|
+
hash
|
96
|
+
end
|
97
|
+
|
98
|
+
def to_json
|
99
|
+
to_hash.to_json
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.from_hash(hash)
|
103
|
+
rule = hash.clone
|
104
|
+
start_time = hash.delete(:start)
|
105
|
+
end_time = hash.delete(:end)
|
106
|
+
|
107
|
+
rule = hash[:freq] && hash[:interval] ? Rule.from_hash(hash) : nil
|
108
|
+
|
109
|
+
s = Schedule.new start_time, rule
|
110
|
+
s.end_time = end_time
|
111
|
+
|
112
|
+
s
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.from_json(json)
|
116
|
+
h = JSON.parse(json)
|
117
|
+
|
118
|
+
h['start'] = Time.parse(h['start']) if h['start']
|
119
|
+
h['end'] = Time.parse(h['end']) if h['end']
|
120
|
+
h['stop'] = Time.parse(h['stop']) if h['stop']
|
121
|
+
|
122
|
+
from_hash(h.symbolize_keys)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Cyclical
|
2
|
+
# Holds suboccurrence of a schedule, i.e. time interval which is a subinterval of a single occurrence.
|
3
|
+
# This is used to find actual time spans to display in a given time interval (for example in a calendar)
|
4
|
+
class Suboccurrence
|
5
|
+
attr_reader :start, :end, :occurrence_start, :occurrence_end
|
6
|
+
|
7
|
+
alias :occurrence_start? :occurrence_start
|
8
|
+
alias :occurrence_end? :occurrence_end
|
9
|
+
|
10
|
+
# factory method for finding suboccurrence of a single occurrence with an interval, with the ability to return nil
|
11
|
+
# This might be a totally bad idea, I'm not sure right now really...
|
12
|
+
def self.find(attrs)
|
13
|
+
raise ArgumentError, "Missing occurrence" unless (occurrence = attrs[:occurrence]).is_a?(Range)
|
14
|
+
raise ArgumentError, "Missing interval" unless (interval = attrs[:interval]).is_a?(Range)
|
15
|
+
|
16
|
+
return nil if occurrence.last <= interval.first || occurrence.first >= interval.last
|
17
|
+
|
18
|
+
suboccurrence = {}
|
19
|
+
|
20
|
+
if occurrence.first < interval.first
|
21
|
+
suboccurrence[:start] = interval.first
|
22
|
+
suboccurrence[:occurrence_start] = false
|
23
|
+
else
|
24
|
+
suboccurrence[:start] = occurrence.first
|
25
|
+
suboccurrence[:occurrence_start] = true
|
26
|
+
end
|
27
|
+
|
28
|
+
if occurrence.last > interval.last
|
29
|
+
suboccurrence[:end] = interval.last
|
30
|
+
suboccurrence[:occurrence_end] = false
|
31
|
+
else
|
32
|
+
suboccurrence[:end] = occurrence.last
|
33
|
+
suboccurrence[:occurrence_end] = true
|
34
|
+
end
|
35
|
+
|
36
|
+
return new(suboccurrence)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def initialize(attrs)
|
42
|
+
@start = attrs[:start]
|
43
|
+
@end = attrs[:end]
|
44
|
+
@occurrence_start = attrs[:occurrence_start]
|
45
|
+
@occurrence_end = attrs[:occurrence_end]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/cyclical.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'active_support/core_ext/time/acts_like'
|
2
|
+
require 'active_support/core_ext/time/calculations'
|
3
|
+
require 'active_support/core_ext/integer/time'
|
4
|
+
require 'active_support/core_ext/numeric/time'
|
5
|
+
|
6
|
+
require 'json'
|
7
|
+
|
8
|
+
require "cyclical/version"
|
9
|
+
require "cyclical/schedule"
|
10
|
+
|
11
|
+
module Cyclical
|
12
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe do
|
4
|
+
before do
|
5
|
+
@filter = MonthdaysFilter.new(1, 3, -5)
|
6
|
+
end
|
7
|
+
|
8
|
+
it "should accept weekdays as constructor arguments" do
|
9
|
+
@filter.monthdays.should == [-5, 1, 3]
|
10
|
+
end
|
11
|
+
|
12
|
+
|
13
|
+
it "should match only the selected monthdays" do
|
14
|
+
filter = MonthdaysFilter.new(1, 3, 17, -6)
|
15
|
+
|
16
|
+
filter.match?(Time.utc(2000, 1, 1)).should be_true
|
17
|
+
filter.match?(Time.utc(2000, 1, 3)).should be_true
|
18
|
+
filter.match?(Time.utc(2000, 1, 17)).should be_true
|
19
|
+
filter.match?(Time.utc(2000, 1, 26)).should be_true
|
20
|
+
|
21
|
+
filter.match?(Time.utc(2000, 2, 26)).should be_false
|
22
|
+
|
23
|
+
filter.match?(Time.utc(2000, 1, 2)).should be_false
|
24
|
+
filter.match?(Time.utc(2000, 1, 4)).should be_false
|
25
|
+
filter.match?(Time.utc(2000, 1, 5)).should be_false
|
26
|
+
filter.match?(Time.utc(2000, 1, 6)).should be_false
|
27
|
+
filter.match?(Time.utc(2000, 1, 8)).should be_false
|
28
|
+
filter.match?(Time.utc(2000, 1, 27)).should be_false
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should provide next valid date" do
|
32
|
+
@filter.next(Time.utc(2010, 1, 1, 21, 13, 11)).should == Time.utc(2010, 1, 1, 21, 13, 11)
|
33
|
+
@filter.next(Time.utc(2010, 1, 2, 21, 13, 11)).should == Time.utc(2010, 1, 3, 21, 13, 11)
|
34
|
+
@filter.next(Time.utc(2010, 1, 18, 21, 13, 11)).should == Time.utc(2010, 1, 27, 21, 13, 11)
|
35
|
+
end
|
36
|
+
end
|