timely 0.0.2 → 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.
- 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
|