cyclical 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Cyclical
2
+ VERSION = "0.1.0"
3
+ 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