timely 0.0.2 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.travis.yml +14 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +1 -12
- data/README.md +5 -0
- data/Rakefile +1 -139
- data/gemfiles/rails3.gemfile +8 -0
- data/gemfiles/rails4.gemfile +9 -0
- data/lib/timely.rb +7 -3
- data/lib/timely/date.rb +20 -0
- data/lib/timely/date_chooser.rb +10 -5
- data/lib/timely/date_range.rb +47 -10
- data/lib/timely/rails.rb +10 -3
- data/lib/timely/rails/calendar_tag.rb +52 -0
- data/lib/timely/rails/date.rb +5 -0
- data/lib/timely/rails/date_group.rb +68 -99
- data/lib/timely/rails/date_range_validity_module.rb +27 -0
- data/lib/timely/rails/date_time.rb +20 -0
- data/lib/timely/rails/extensions.rb +23 -11
- data/lib/timely/rails/period.rb +31 -0
- data/lib/timely/rails/season.rb +65 -75
- data/lib/timely/railtie.rb +7 -0
- data/lib/timely/temporal_patterns/finder.rb +152 -0
- data/lib/timely/temporal_patterns/frequency.rb +108 -0
- data/lib/timely/temporal_patterns/interval.rb +67 -0
- data/lib/timely/temporal_patterns/pattern.rb +160 -0
- data/lib/timely/time_since.rb +17 -0
- data/lib/timely/version.rb +3 -0
- data/spec/calendar_tag_spec.rb +29 -0
- data/spec/date_chooser_spec.rb +36 -27
- data/spec/date_group_spec.rb +9 -9
- data/spec/date_range_spec.rb +58 -20
- data/spec/date_spec.rb +20 -12
- data/spec/extensions_spec.rb +32 -0
- data/spec/rails/date_spec.rb +16 -0
- data/spec/rails/date_time_spec.rb +20 -0
- data/spec/rails/period_spec.rb +17 -0
- data/spec/schema.rb +5 -0
- data/spec/season_spec.rb +21 -24
- data/spec/spec_helper.rb +5 -20
- data/spec/string_spec.rb +4 -3
- data/spec/support/coverage.rb +26 -0
- data/spec/temporal_patterns_spec.rb +28 -0
- data/spec/time_since_spec.rb +24 -0
- data/spec/time_spec.rb +14 -14
- data/spec/trackable_date_set_spec.rb +14 -14
- data/spec/week_days_spec.rb +18 -18
- data/timely.gemspec +34 -98
- metadata +244 -21
- data/lib/timely/temporal_patterns.rb +0 -441
@@ -1,441 +0,0 @@
|
|
1
|
-
module TemporalPatterns
|
2
|
-
class Frequency
|
3
|
-
UNITS = [:second, :minute, :hour, :day, :week, :fortnight, :month, :year]
|
4
|
-
|
5
|
-
attr_accessor :duration
|
6
|
-
|
7
|
-
def initialize(duration)
|
8
|
-
self.duration = duration
|
9
|
-
end
|
10
|
-
|
11
|
-
def duration=(duration)
|
12
|
-
raise ArgumentError, "Frequency (#{duration}) must be a duration" unless duration.is_a?(ActiveSupport::Duration)
|
13
|
-
raise ArgumentError, "Frequency (#{duration}) must be positive" unless duration > 0
|
14
|
-
@duration = self.class.parse(duration)
|
15
|
-
end
|
16
|
-
|
17
|
-
def <=>(other)
|
18
|
-
self.duration <=> other.duration
|
19
|
-
end
|
20
|
-
|
21
|
-
def to_s
|
22
|
-
"every #{duration.inspect}"
|
23
|
-
end
|
24
|
-
|
25
|
-
def unit
|
26
|
-
parts = self.class.decompose(duration)
|
27
|
-
parts.size == 1 && parts.first.last == 1? parts.first.first : nil
|
28
|
-
end
|
29
|
-
|
30
|
-
def min_unit
|
31
|
-
parts = self.class.decompose(duration)
|
32
|
-
{parts.last.first => parts.last.last}
|
33
|
-
end
|
34
|
-
|
35
|
-
def max_unit
|
36
|
-
parts = self.class.decompose(duration)
|
37
|
-
{parts.first.first => parts.first.last}
|
38
|
-
end
|
39
|
-
|
40
|
-
def units
|
41
|
-
self.class.decompose_to_hash(duration)
|
42
|
-
end
|
43
|
-
|
44
|
-
class << self
|
45
|
-
def singular_units
|
46
|
-
UNITS.dup
|
47
|
-
end
|
48
|
-
|
49
|
-
def plural_units
|
50
|
-
UNITS.map { |unit| unit.to_s.pluralize.to_sym }
|
51
|
-
end
|
52
|
-
|
53
|
-
def unit_durations
|
54
|
-
UNITS.map { |unit| 1.call(unit) }
|
55
|
-
end
|
56
|
-
|
57
|
-
def valid_units
|
58
|
-
singular_units + plural_units
|
59
|
-
end
|
60
|
-
|
61
|
-
def valid_unit?(unit)
|
62
|
-
valid_units.include?(unit.to_s.to_sym)
|
63
|
-
end
|
64
|
-
|
65
|
-
def parse(duration)
|
66
|
-
parsed = 0.seconds
|
67
|
-
decompose(duration).each do |part|
|
68
|
-
parsed += part.last.send(part.first)
|
69
|
-
end
|
70
|
-
parsed
|
71
|
-
end
|
72
|
-
|
73
|
-
def decompose(duration)
|
74
|
-
whole = duration
|
75
|
-
parts = []
|
76
|
-
plural_units.reverse_each do |unit|
|
77
|
-
if whole >= (one_unit = 1.send(unit))
|
78
|
-
current_unit_value = ((whole / one_unit).floor)
|
79
|
-
if current_unit_value > 0
|
80
|
-
parts << [unit, current_unit_value]
|
81
|
-
whole -= current_unit_value.send(unit)
|
82
|
-
end
|
83
|
-
end
|
84
|
-
end
|
85
|
-
parts
|
86
|
-
end
|
87
|
-
|
88
|
-
def decompose_to_hash(duration)
|
89
|
-
decompose(duration).inject({}) do |hash, unit|
|
90
|
-
hash[unit.first] = unit.last
|
91
|
-
hash
|
92
|
-
end
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
private
|
97
|
-
|
98
|
-
def method_missing(method, *args, &block) #:nodoc:
|
99
|
-
duration.send(method, *args, &block)
|
100
|
-
end
|
101
|
-
end
|
102
|
-
|
103
|
-
class Interval
|
104
|
-
attr_accessor :first_datetime, :last_datetime
|
105
|
-
|
106
|
-
def initialize(first_datetime, last_datetime = nil)
|
107
|
-
self.first_datetime = first_datetime
|
108
|
-
self.last_datetime = last_datetime || first_datetime
|
109
|
-
end
|
110
|
-
|
111
|
-
def first_datetime=(first_datetime)
|
112
|
-
@first_datetime = first_datetime.to_datetime
|
113
|
-
end
|
114
|
-
|
115
|
-
def last_datetime=(last_datetime)
|
116
|
-
@last_datetime = last_datetime.to_datetime
|
117
|
-
end
|
118
|
-
|
119
|
-
def range
|
120
|
-
(first_datetime..last_datetime)
|
121
|
-
end
|
122
|
-
|
123
|
-
def datetimes
|
124
|
-
range.to_a
|
125
|
-
end
|
126
|
-
|
127
|
-
def ==(other)
|
128
|
-
self.range == other.range
|
129
|
-
end
|
130
|
-
|
131
|
-
def to_s
|
132
|
-
if first_datetime == last_datetime
|
133
|
-
"on #{first_datetime}#{first_datetime == first_datetime.beginning_of_day ? "" : " at #{first_datetime.strftime("%I:%M %p")}"}"
|
134
|
-
elsif first_datetime == first_datetime.beginning_of_month && last_datetime == last_datetime.end_of_month
|
135
|
-
if first_datetime.month == last_datetime.month
|
136
|
-
"during #{first_datetime.strftime('%b %Y')}"
|
137
|
-
else
|
138
|
-
"from #{first_datetime.strftime('%b %Y')} to #{last_datetime.strftime('%b %Y')}"
|
139
|
-
end
|
140
|
-
else
|
141
|
-
"from #{first_datetime}#{first_datetime == first_datetime.beginning_of_day ? "" : " at #{first_datetime.strftime("%I:%M %p")}"} "+
|
142
|
-
"to #{last_datetime}#{last_datetime == last_datetime.beginning_of_day ? "" : " at #{last_datetime.strftime("%I:%M %p")}"}"
|
143
|
-
end
|
144
|
-
end
|
145
|
-
|
146
|
-
def date_time_to_s(datetime)
|
147
|
-
datetime.strftime("%I:%M %p")
|
148
|
-
end
|
149
|
-
|
150
|
-
private
|
151
|
-
|
152
|
-
def method_missing(method, *args, &block) #:nodoc:
|
153
|
-
range.send(method, *args, &block)
|
154
|
-
end
|
155
|
-
end
|
156
|
-
|
157
|
-
class Pattern
|
158
|
-
attr_reader :intervals, :frequency
|
159
|
-
|
160
|
-
def initialize(ranges, frequency)
|
161
|
-
@intervals = Array.wrap(ranges).map { |r| Interval.new(r.first, r.last) }.sort_by(&:first_datetime)
|
162
|
-
@frequency = Frequency.new(frequency)
|
163
|
-
fix_frequency
|
164
|
-
end
|
165
|
-
|
166
|
-
def datetimes
|
167
|
-
intervals.map do |interval|
|
168
|
-
datetimes = []
|
169
|
-
datetime = interval.first_datetime
|
170
|
-
while datetime <= interval.last_datetime
|
171
|
-
datetimes << datetime
|
172
|
-
datetime = datetime + frequency.duration
|
173
|
-
end
|
174
|
-
datetimes
|
175
|
-
end
|
176
|
-
end
|
177
|
-
|
178
|
-
def ranges
|
179
|
-
intervals.map { |i| (i.first_datetime..i.last_datetime) }
|
180
|
-
end
|
181
|
-
|
182
|
-
def first_datetime
|
183
|
-
interval.first_datetime
|
184
|
-
end
|
185
|
-
|
186
|
-
def last_datetime
|
187
|
-
interval.last_datetime
|
188
|
-
end
|
189
|
-
|
190
|
-
def interval
|
191
|
-
first_datetime = nil
|
192
|
-
last_datetime = nil
|
193
|
-
intervals.each do |i|
|
194
|
-
first_datetime = i.first_datetime if first_datetime.nil? || i.first_datetime < first_datetime
|
195
|
-
last_datetime = i.last_datetime if last_datetime.nil? || i.last_datetime > last_datetime
|
196
|
-
end
|
197
|
-
Interval.new(first_datetime, last_datetime)
|
198
|
-
end
|
199
|
-
|
200
|
-
def match?(datetimes)
|
201
|
-
datetimes = Array.wrap(datetimes).map(&:to_datetime)
|
202
|
-
intervals.each do |interval|
|
203
|
-
current_datetime = interval.first_datetime
|
204
|
-
while current_datetime <= interval.last_datetime
|
205
|
-
datetimes.delete_if { |datetime| datetime == current_datetime }
|
206
|
-
return true if datetimes.empty?
|
207
|
-
current_datetime = current_datetime + frequency.duration
|
208
|
-
end
|
209
|
-
end
|
210
|
-
false
|
211
|
-
end
|
212
|
-
|
213
|
-
def <=>(other)
|
214
|
-
self.intervals.count <=> other.intervals.count
|
215
|
-
end
|
216
|
-
|
217
|
-
def join(other)
|
218
|
-
if self.frequency == other.frequency
|
219
|
-
expanded_datetimes = self.datetimes.map do |datetimes|
|
220
|
-
datetimes.unshift(datetimes.first - frequency.duration)
|
221
|
-
datetimes << (datetimes.last + frequency.duration)
|
222
|
-
end
|
223
|
-
joint_ranges = []
|
224
|
-
other.datetimes.each do |datetimes|
|
225
|
-
if joinable_datetimes = expanded_datetimes.find { |ed| datetimes.any? { |d| ed.include?(d) } }
|
226
|
-
joint_datetimes = (datetimes + joinable_datetimes[1...-1]).sort
|
227
|
-
joint_ranges << (joint_datetimes.first..joint_datetimes.last)
|
228
|
-
else
|
229
|
-
break
|
230
|
-
end
|
231
|
-
end
|
232
|
-
unless joint_ranges.size != self.intervals.size
|
233
|
-
Pattern.new(joint_ranges, frequency.duration)
|
234
|
-
end
|
235
|
-
end
|
236
|
-
end
|
237
|
-
|
238
|
-
def to_s
|
239
|
-
single_date_intervals, multiple_dates_intervals = intervals.partition { |i| i.first_datetime == i.last_datetime}
|
240
|
-
patterns_strings = if multiple_dates_intervals.empty?
|
241
|
-
single_date_intervals.map(&:to_s)
|
242
|
-
else
|
243
|
-
multiple_dates_intervals_first_datetime = nil
|
244
|
-
multiple_dates_intervals_last_datetime = nil
|
245
|
-
multiple_dates_intervals.each do |i|
|
246
|
-
multiple_dates_intervals_first_datetime = i.first_datetime if multiple_dates_intervals_first_datetime.nil? || i.first_datetime < multiple_dates_intervals_first_datetime
|
247
|
-
multiple_dates_intervals_last_datetime = i.last_datetime if multiple_dates_intervals_last_datetime.nil? || i.last_datetime > multiple_dates_intervals_last_datetime
|
248
|
-
end
|
249
|
-
multiple_dates_interval = Interval.new(multiple_dates_intervals_first_datetime, multiple_dates_intervals_last_datetime)
|
250
|
-
multiple_dates_intervals_string = case frequency.unit
|
251
|
-
when :years
|
252
|
-
"every #{multiple_dates_intervals.map { |i| "#{i.first_datetime.day.ordinalize} of #{i.first_datetime.strftime('%B')}" }.uniq.to_sentence} #{multiple_dates_interval}"
|
253
|
-
when :months
|
254
|
-
"every #{multiple_dates_intervals.map { |i| i.first_datetime.day.ordinalize }.uniq.to_sentence} of the month #{multiple_dates_interval}"
|
255
|
-
when :weeks
|
256
|
-
weekdays = multiple_dates_intervals.map { |i| i.first_datetime.strftime('%A') }.uniq
|
257
|
-
if weekdays.count == 7
|
258
|
-
"every day #{multiple_dates_interval}"
|
259
|
-
else
|
260
|
-
"every #{weekdays.to_sentence} #{multiple_dates_interval}"
|
261
|
-
end
|
262
|
-
when :days
|
263
|
-
if multiple_dates_intervals.any? { |i| i.first_datetime != i.first_datetime.beginning_of_day }
|
264
|
-
"every day at #{multiple_dates_intervals.map { |i| i.first_datetime.strftime("%I:%M %p") }.to_sentence} #{multiple_dates_interval}"
|
265
|
-
else
|
266
|
-
"every day #{multiple_dates_interval}"
|
267
|
-
end
|
268
|
-
else
|
269
|
-
"#{frequency} #{multiple_dates_intervals.map(&:to_s).to_sentence}"
|
270
|
-
end
|
271
|
-
[multiple_dates_intervals_string] + single_date_intervals.map(&:to_s)
|
272
|
-
end
|
273
|
-
patterns_strings.to_sentence
|
274
|
-
end
|
275
|
-
|
276
|
-
private
|
277
|
-
|
278
|
-
# Fix the time units inconsistency problem
|
279
|
-
# e.g.: a year isn't exactly 12 months, it's a little bit more, but it is commonly considered to be equal to 12 months
|
280
|
-
def fix_frequency
|
281
|
-
if frequency.duration > 12.months
|
282
|
-
if intervals.all? { |i| i.first_datetime.month == i.last_datetime.month && i.first_datetime.day == i.last_datetime.day }
|
283
|
-
frequency.duration = (frequency.duration / 12.months).floor.years
|
284
|
-
end
|
285
|
-
elsif frequency.duration > 1.month && frequency.duration < 12.months
|
286
|
-
if intervals.all? { |i| i.first_datetime.day == i.last_datetime.day }
|
287
|
-
frequency.duration = frequency.units[:months].months
|
288
|
-
end
|
289
|
-
end
|
290
|
-
end
|
291
|
-
end
|
292
|
-
|
293
|
-
class Finder
|
294
|
-
class << self
|
295
|
-
def patterns(datetimes, options = nil)
|
296
|
-
return [] if datetimes.blank?
|
297
|
-
options ||= {}
|
298
|
-
|
299
|
-
datetimes = Array.wrap(datetimes).uniq.map(&:to_datetime).sort
|
300
|
-
|
301
|
-
if options[:split].is_a?(ActiveSupport::Duration)
|
302
|
-
find_patterns_split_by_duration(datetimes, options)
|
303
|
-
elsif options[:split].is_a?(Numeric)
|
304
|
-
find_patterns_split_by_size(datetimes, options)
|
305
|
-
else
|
306
|
-
find_patterns(datetimes, options)
|
307
|
-
end
|
308
|
-
end
|
309
|
-
|
310
|
-
private
|
311
|
-
|
312
|
-
def find_patterns(datetimes, options = {})
|
313
|
-
frequency_patterns = []
|
314
|
-
|
315
|
-
return [] if datetimes.blank?
|
316
|
-
|
317
|
-
if frequencies = options[:frequency] || options[:frequencies]
|
318
|
-
Array.wrap(frequencies).each do |frequency|
|
319
|
-
unmatched_datetimes = nil
|
320
|
-
if pattern = frequency_pattern(datetimes, frequency)
|
321
|
-
frequency_patterns << pattern
|
322
|
-
unmatched_datetimes = datetimes - pattern[:intervals].flatten
|
323
|
-
end
|
324
|
-
break if unmatched_datetimes && unmatched_datetimes.empty?
|
325
|
-
end
|
326
|
-
end
|
327
|
-
|
328
|
-
if options[:all] || !frequencies
|
329
|
-
frequency_patterns.concat(frequency_patterns(datetimes))
|
330
|
-
end
|
331
|
-
|
332
|
-
if best_fit = best_pattern(frequency_patterns)
|
333
|
-
ranges = best_fit[:ranges].map { |r| (r[0]..r[1]) }
|
334
|
-
frequency = best_fit[:frequency]
|
335
|
-
unmatched_datetimes = datetimes - best_fit[:intervals].flatten
|
336
|
-
pattern = Pattern.new(ranges, frequency)
|
337
|
-
([pattern] + find_patterns(unmatched_datetimes, options)).sort_by(&:first_datetime)
|
338
|
-
else
|
339
|
-
datetimes.map { |d| Pattern.new((d..d), 1.day) }.sort_by(&:first_datetime)
|
340
|
-
end
|
341
|
-
end
|
342
|
-
|
343
|
-
def find_patterns_split_by_duration(datetimes, options = {})
|
344
|
-
slice_size = options[:split]
|
345
|
-
slices = []
|
346
|
-
slice_start = 0
|
347
|
-
while slice_start < datetimes.size
|
348
|
-
slice_end = datetimes.index(datetimes[slice_start] + slice_size) || (datetimes.size - 1)
|
349
|
-
slices << datetimes[slice_start..slice_end]
|
350
|
-
slice_start = slice_end + 1
|
351
|
-
end
|
352
|
-
split_patterns = []
|
353
|
-
slices.each do |slice|
|
354
|
-
split_patterns.concat(find_patterns(slice, options))
|
355
|
-
end
|
356
|
-
join_patterns(split_patterns)
|
357
|
-
end
|
358
|
-
|
359
|
-
def find_patterns_split_by_size(datetimes, options = {})
|
360
|
-
slice_size = options[:split]
|
361
|
-
split_patterns = []
|
362
|
-
datetimes.each_slice(slice_size) do |slice|
|
363
|
-
split_patterns.concat(find_patterns(slice, options))
|
364
|
-
end
|
365
|
-
join_patterns(split_patterns)
|
366
|
-
end
|
367
|
-
|
368
|
-
def join_patterns(patterns)
|
369
|
-
split_patterns = patterns.sort_by(&:first_datetime)
|
370
|
-
joint_patterns = []
|
371
|
-
while pattern = split_patterns.pop
|
372
|
-
joint_pattern = pattern
|
373
|
-
while (next_pattern = split_patterns.pop) && (pattern = joint_pattern.join(next_pattern))
|
374
|
-
joint_pattern = pattern
|
375
|
-
end
|
376
|
-
joint_patterns << joint_pattern
|
377
|
-
end
|
378
|
-
joint_patterns.sort_by(&:first_datetime)
|
379
|
-
end
|
380
|
-
|
381
|
-
def frequency_pattern(datetimes, frequency)
|
382
|
-
return nil if datetimes.blank? || frequency.to_f < 1 || ((datetimes.first + frequency) > datetimes.last)
|
383
|
-
pattern_intervals = []
|
384
|
-
pattern_ranges = []
|
385
|
-
intervals = condition_intervals(datetimes) do |current_date, next_date|
|
386
|
-
if (current_date + frequency) == next_date
|
387
|
-
pattern_intervals << [current_date, next_date]
|
388
|
-
true
|
389
|
-
else
|
390
|
-
false
|
391
|
-
end
|
392
|
-
end
|
393
|
-
pattern_ranges = intervals.map { |r| [r.first,r.last] }
|
394
|
-
{:frequency => frequency, :ranges => pattern_ranges, :intervals => pattern_intervals} unless intervals.blank?
|
395
|
-
end
|
396
|
-
|
397
|
-
def condition_intervals(datetimes, &block)
|
398
|
-
return [] if datetimes.blank? || !block_given?
|
399
|
-
datetimes = datetimes.clone
|
400
|
-
current_datetime = first_datetime = datetimes.shift
|
401
|
-
last_datetime = nil
|
402
|
-
while next_datetime = datetimes.delete(datetimes.find { |datetime| block.call(current_datetime, datetime) })
|
403
|
-
current_datetime = last_datetime = next_datetime
|
404
|
-
end
|
405
|
-
(last_datetime ? [(first_datetime..current_datetime)] : []) + condition_intervals(datetimes, &block)
|
406
|
-
end
|
407
|
-
|
408
|
-
def frequency_patterns(datetimes)
|
409
|
-
return [] if datetimes.blank?
|
410
|
-
datetimes = datetimes.clone
|
411
|
-
patterns = {}
|
412
|
-
while (current_datetime = datetimes.pop)
|
413
|
-
datetimes.reverse_each do |compared_datetime|
|
414
|
-
frequency = current_datetime - compared_datetime
|
415
|
-
patterns[frequency] ||= {:frequency => frequency.days, :ranges => [], :intervals => []}
|
416
|
-
patterns[frequency][:intervals] << [compared_datetime, current_datetime]
|
417
|
-
if interval = patterns[frequency][:ranges].find { |i| i[0] == current_datetime }
|
418
|
-
interval[0] = compared_datetime
|
419
|
-
else
|
420
|
-
patterns[frequency][:ranges] << [compared_datetime, current_datetime]
|
421
|
-
end
|
422
|
-
end
|
423
|
-
end
|
424
|
-
patterns.values
|
425
|
-
end
|
426
|
-
|
427
|
-
def best_pattern(frequency_patterns)
|
428
|
-
best_fit = nil
|
429
|
-
frequency_patterns.each do |pattern_hash|
|
430
|
-
if best_fit.nil? ||
|
431
|
-
(best_fit[:intervals].count < pattern_hash[:intervals].count) ||
|
432
|
-
(best_fit[:intervals].count == pattern_hash[:intervals].count && (best_fit[:ranges].count > pattern_hash[:ranges].count ||
|
433
|
-
(best_fit[:ranges].count == pattern_hash[:ranges].count && best_fit[:frequency] < pattern_hash[:frequency])))
|
434
|
-
best_fit = pattern_hash
|
435
|
-
end
|
436
|
-
end
|
437
|
-
best_fit
|
438
|
-
end
|
439
|
-
end
|
440
|
-
end
|
441
|
-
end
|