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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +14 -0
  5. data/CHANGELOG.md +5 -0
  6. data/Gemfile +1 -12
  7. data/README.md +5 -0
  8. data/Rakefile +1 -139
  9. data/gemfiles/rails3.gemfile +8 -0
  10. data/gemfiles/rails4.gemfile +9 -0
  11. data/lib/timely.rb +7 -3
  12. data/lib/timely/date.rb +20 -0
  13. data/lib/timely/date_chooser.rb +10 -5
  14. data/lib/timely/date_range.rb +47 -10
  15. data/lib/timely/rails.rb +10 -3
  16. data/lib/timely/rails/calendar_tag.rb +52 -0
  17. data/lib/timely/rails/date.rb +5 -0
  18. data/lib/timely/rails/date_group.rb +68 -99
  19. data/lib/timely/rails/date_range_validity_module.rb +27 -0
  20. data/lib/timely/rails/date_time.rb +20 -0
  21. data/lib/timely/rails/extensions.rb +23 -11
  22. data/lib/timely/rails/period.rb +31 -0
  23. data/lib/timely/rails/season.rb +65 -75
  24. data/lib/timely/railtie.rb +7 -0
  25. data/lib/timely/temporal_patterns/finder.rb +152 -0
  26. data/lib/timely/temporal_patterns/frequency.rb +108 -0
  27. data/lib/timely/temporal_patterns/interval.rb +67 -0
  28. data/lib/timely/temporal_patterns/pattern.rb +160 -0
  29. data/lib/timely/time_since.rb +17 -0
  30. data/lib/timely/version.rb +3 -0
  31. data/spec/calendar_tag_spec.rb +29 -0
  32. data/spec/date_chooser_spec.rb +36 -27
  33. data/spec/date_group_spec.rb +9 -9
  34. data/spec/date_range_spec.rb +58 -20
  35. data/spec/date_spec.rb +20 -12
  36. data/spec/extensions_spec.rb +32 -0
  37. data/spec/rails/date_spec.rb +16 -0
  38. data/spec/rails/date_time_spec.rb +20 -0
  39. data/spec/rails/period_spec.rb +17 -0
  40. data/spec/schema.rb +5 -0
  41. data/spec/season_spec.rb +21 -24
  42. data/spec/spec_helper.rb +5 -20
  43. data/spec/string_spec.rb +4 -3
  44. data/spec/support/coverage.rb +26 -0
  45. data/spec/temporal_patterns_spec.rb +28 -0
  46. data/spec/time_since_spec.rb +24 -0
  47. data/spec/time_spec.rb +14 -14
  48. data/spec/trackable_date_set_spec.rb +14 -14
  49. data/spec/week_days_spec.rb +18 -18
  50. data/timely.gemspec +34 -98
  51. metadata +244 -21
  52. 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