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
@@ -0,0 +1,7 @@
1
+ class Railtie < Rails::Railtie
2
+ initializer 'timely.initialize' do
3
+ ActiveSupport.on_load(:action_view) do
4
+ require 'timely/rails'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,152 @@
1
+ module Timely
2
+ module TemporalPatterns
3
+ class Finder
4
+ class << self
5
+ def patterns(datetimes, options = nil)
6
+ return [] if datetimes.blank?
7
+ options ||= {}
8
+
9
+ datetimes = Array.wrap(datetimes).uniq.map(&:to_datetime).sort
10
+
11
+ if options[:split].is_a?(ActiveSupport::Duration)
12
+ find_patterns_split_by_duration(datetimes, options)
13
+ elsif options[:split].is_a?(Numeric)
14
+ find_patterns_split_by_size(datetimes, options)
15
+ else
16
+ find_patterns(datetimes, options)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def find_patterns(datetimes, options = {})
23
+ frequency_patterns = []
24
+
25
+ return [] if datetimes.blank?
26
+
27
+ if frequencies = options[:frequency] || options[:frequencies]
28
+ Array.wrap(frequencies).each do |frequency|
29
+ unmatched_datetimes = nil
30
+ if pattern = frequency_pattern(datetimes, frequency)
31
+ frequency_patterns << pattern
32
+ unmatched_datetimes = datetimes - pattern[:intervals].flatten
33
+ end
34
+ break if unmatched_datetimes && unmatched_datetimes.empty?
35
+ end
36
+ end
37
+
38
+ if options[:all] || !frequencies
39
+ frequency_patterns.concat(frequency_patterns(datetimes))
40
+ end
41
+
42
+ if best_fit = best_pattern(frequency_patterns)
43
+ ranges = best_fit[:ranges].map { |r| (r[0]..r[1]) }
44
+ frequency = best_fit[:frequency]
45
+ unmatched_datetimes = datetimes - best_fit[:intervals].flatten
46
+ pattern = Pattern.new(ranges, frequency)
47
+ ([pattern] + find_patterns(unmatched_datetimes, options)).sort_by(&:first_datetime)
48
+ else
49
+ datetimes.map { |d| Pattern.new((d..d), 1.day) }.sort_by(&:first_datetime)
50
+ end
51
+ end
52
+
53
+ def find_patterns_split_by_duration(datetimes, options = {})
54
+ slice_size = options[:split]
55
+ slices = []
56
+ slice_start = 0
57
+ while slice_start < datetimes.size
58
+ slice_end = datetimes.index(datetimes[slice_start] + slice_size) || (datetimes.size - 1)
59
+ slices << datetimes[slice_start..slice_end]
60
+ slice_start = slice_end + 1
61
+ end
62
+ split_patterns = []
63
+ slices.each do |slice|
64
+ split_patterns.concat(find_patterns(slice, options))
65
+ end
66
+ join_patterns(split_patterns)
67
+ end
68
+
69
+ def find_patterns_split_by_size(datetimes, options = {})
70
+ slice_size = options[:split]
71
+ split_patterns = []
72
+ datetimes.each_slice(slice_size) do |slice|
73
+ split_patterns.concat(find_patterns(slice, options))
74
+ end
75
+ join_patterns(split_patterns)
76
+ end
77
+
78
+ def join_patterns(patterns)
79
+ split_patterns = patterns.sort_by(&:first_datetime)
80
+ joint_patterns = []
81
+ while pattern = split_patterns.pop
82
+ joint_pattern = pattern
83
+ while (next_pattern = split_patterns.pop) && (pattern = joint_pattern.join(next_pattern))
84
+ joint_pattern = pattern
85
+ end
86
+ joint_patterns << joint_pattern
87
+ end
88
+ joint_patterns.sort_by(&:first_datetime)
89
+ end
90
+
91
+ def frequency_pattern(datetimes, frequency)
92
+ return nil if datetimes.blank? || frequency.to_f < 1 || ((datetimes.first + frequency) > datetimes.last)
93
+ pattern_intervals = []
94
+ pattern_ranges = []
95
+ intervals = condition_intervals(datetimes) do |current_date, next_date|
96
+ if (current_date + frequency) == next_date
97
+ pattern_intervals << [current_date, next_date]
98
+ true
99
+ else
100
+ false
101
+ end
102
+ end
103
+ pattern_ranges = intervals.map { |r| [r.first,r.last] }
104
+ {:frequency => frequency, :ranges => pattern_ranges, :intervals => pattern_intervals} unless intervals.blank?
105
+ end
106
+
107
+ def condition_intervals(datetimes, &block)
108
+ return [] if datetimes.blank? || !block_given?
109
+ datetimes = datetimes.clone
110
+ current_datetime = first_datetime = datetimes.shift
111
+ last_datetime = nil
112
+ while next_datetime = datetimes.delete(datetimes.find { |datetime| block.call(current_datetime, datetime) })
113
+ current_datetime = last_datetime = next_datetime
114
+ end
115
+ (last_datetime ? [(first_datetime..current_datetime)] : []) + condition_intervals(datetimes, &block)
116
+ end
117
+
118
+ def frequency_patterns(datetimes)
119
+ return [] if datetimes.blank?
120
+ datetimes = datetimes.clone
121
+ patterns = {}
122
+ while (current_datetime = datetimes.pop)
123
+ datetimes.reverse_each do |compared_datetime|
124
+ frequency = current_datetime - compared_datetime
125
+ patterns[frequency] ||= {:frequency => frequency.days, :ranges => [], :intervals => []}
126
+ patterns[frequency][:intervals] << [compared_datetime, current_datetime]
127
+ if interval = patterns[frequency][:ranges].find { |i| i[0] == current_datetime }
128
+ interval[0] = compared_datetime
129
+ else
130
+ patterns[frequency][:ranges] << [compared_datetime, current_datetime]
131
+ end
132
+ end
133
+ end
134
+ patterns.values
135
+ end
136
+
137
+ def best_pattern(frequency_patterns)
138
+ best_fit = nil
139
+ frequency_patterns.each do |pattern_hash|
140
+ if best_fit.nil? ||
141
+ (best_fit[:intervals].count < pattern_hash[:intervals].count) ||
142
+ (best_fit[:intervals].count == pattern_hash[:intervals].count && (best_fit[:ranges].count > pattern_hash[:ranges].count ||
143
+ (best_fit[:ranges].count == pattern_hash[:ranges].count && best_fit[:frequency] < pattern_hash[:frequency])))
144
+ best_fit = pattern_hash
145
+ end
146
+ end
147
+ best_fit
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,108 @@
1
+ module Timely
2
+ module TemporalPatterns
3
+ class Frequency
4
+ UNITS = [:second, :minute, :hour, :day, :week, :fortnight, :month, :year]
5
+
6
+ attr_accessor :duration
7
+
8
+ def initialize(duration)
9
+ self.duration = duration
10
+ end
11
+
12
+ def duration=(duration)
13
+ raise ArgumentError, "Frequency (#{duration}) must be a duration" unless duration.is_a?(ActiveSupport::Duration)
14
+ raise ArgumentError, "Frequency (#{duration}) must be positive" unless duration > 0
15
+ @duration = self.class.parse(duration)
16
+ end
17
+
18
+ def <=>(other)
19
+ self.duration <=> other.duration
20
+ end
21
+
22
+ def to_s
23
+ "every " + duration.parts.
24
+ reduce(::Hash.new(0)) { |h,(l,r)| h[l] += r; h }.
25
+ sort_by {|unit, _ | [:years, :months, :days, :minutes, :seconds].index(unit)}.
26
+ map {|unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}" if val.nonzero?}.compact.
27
+ to_sentence(:locale => :en)
28
+ end
29
+
30
+ def unit
31
+ parts = self.class.decompose(duration)
32
+ parts.size == 1 && parts.first.last == 1? parts.first.first : nil
33
+ end
34
+
35
+ def min_unit
36
+ parts = self.class.decompose(duration)
37
+ {parts.last.first => parts.last.last}
38
+ end
39
+
40
+ def max_unit
41
+ parts = self.class.decompose(duration)
42
+ {parts.first.first => parts.first.last}
43
+ end
44
+
45
+ def units
46
+ self.class.decompose_to_hash(duration)
47
+ end
48
+
49
+ class << self
50
+ def singular_units
51
+ UNITS.dup
52
+ end
53
+
54
+ def plural_units
55
+ UNITS.map { |unit| unit.to_s.pluralize.to_sym }
56
+ end
57
+
58
+ def unit_durations
59
+ UNITS.map { |unit| 1.call(unit) }
60
+ end
61
+
62
+ def valid_units
63
+ singular_units + plural_units
64
+ end
65
+
66
+ def valid_unit?(unit)
67
+ valid_units.include?(unit.to_s.to_sym)
68
+ end
69
+
70
+ def parse(duration)
71
+ parsed = 0.seconds
72
+ decompose(duration).each do |part|
73
+ parsed += part.last.send(part.first)
74
+ end
75
+ parsed
76
+ end
77
+
78
+ def decompose(duration)
79
+ whole = duration
80
+ parts = []
81
+ plural_units.reverse_each do |unit|
82
+ if whole >= (one_unit = 1.send(unit))
83
+ current_unit_value = ((whole / one_unit).floor)
84
+ if current_unit_value > 0
85
+ parts << [unit, current_unit_value]
86
+ whole -= current_unit_value.send(unit)
87
+ end
88
+ end
89
+ end
90
+ parts
91
+ end
92
+
93
+ def decompose_to_hash(duration)
94
+ decompose(duration).inject({}) do |hash, unit|
95
+ hash[unit.first] = unit.last
96
+ hash
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def method_missing(method, *args, &block) #:nodoc:
104
+ duration.send(method, *args, &block)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,67 @@
1
+ module Timely
2
+ module TemporalPatterns
3
+ class Interval
4
+ attr_accessor :first_datetime, :last_datetime
5
+
6
+ def self.surrounding(intervals)
7
+ first_datetime = nil
8
+ last_datetime = nil
9
+ intervals.each do |i|
10
+ first_datetime = i.first_datetime if first_datetime.nil? || i.first_datetime < first_datetime
11
+ last_datetime = i.last_datetime if last_datetime.nil? || i.last_datetime > last_datetime
12
+ end
13
+ new(first_datetime, last_datetime)
14
+ end
15
+
16
+ def initialize(first_datetime, last_datetime = nil)
17
+ self.first_datetime = first_datetime
18
+ self.last_datetime = last_datetime || first_datetime
19
+ end
20
+
21
+ def first_datetime=(first_datetime)
22
+ @first_datetime = first_datetime.to_datetime
23
+ end
24
+
25
+ def last_datetime=(last_datetime)
26
+ @last_datetime = last_datetime.to_datetime
27
+ end
28
+
29
+ def range
30
+ (first_datetime..last_datetime)
31
+ end
32
+
33
+ def datetimes
34
+ range.to_a
35
+ end
36
+
37
+ def ==(other)
38
+ self.range == other.range
39
+ end
40
+
41
+ def to_s
42
+ if first_datetime == last_datetime
43
+ "on #{first_datetime}#{first_datetime == first_datetime.beginning_of_day ? "" : " at #{first_datetime.strftime("%I:%M %p")}"}"
44
+ elsif first_datetime == first_datetime.beginning_of_month && last_datetime == last_datetime.end_of_month
45
+ if first_datetime.month == last_datetime.month
46
+ "during #{first_datetime.strftime('%b %Y')}"
47
+ else
48
+ "from #{first_datetime.strftime('%b %Y')} to #{last_datetime.strftime('%b %Y')}"
49
+ end
50
+ else
51
+ "from #{first_datetime}#{first_datetime == first_datetime.beginning_of_day ? "" : " at #{first_datetime.strftime("%I:%M %p")}"} "+
52
+ "to #{last_datetime}#{last_datetime == last_datetime.beginning_of_day ? "" : " at #{last_datetime.strftime("%I:%M %p")}"}"
53
+ end
54
+ end
55
+
56
+ def date_time_to_s(datetime)
57
+ datetime.strftime("%I:%M %p")
58
+ end
59
+
60
+ private
61
+
62
+ def method_missing(method, *args, &block) #:nodoc:
63
+ range.send(method, *args, &block)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,160 @@
1
+ require 'active_support/core_ext/integer/inflections' # ordinalize
2
+
3
+ module Timely
4
+ module TemporalPatterns
5
+ class Pattern
6
+ attr_reader :intervals, :frequency
7
+
8
+ def initialize(ranges, frequency)
9
+ @intervals = Array.wrap(ranges).map { |r| Interval.new(r.first, r.last) }.sort_by(&:first_datetime)
10
+ @frequency = Frequency.new(frequency)
11
+ fix_frequency
12
+ end
13
+
14
+ # Convert each interval to a list of datetimes
15
+ def datetimes
16
+ intervals.map do |interval|
17
+ datetimes = []
18
+ datetime = interval.first_datetime
19
+ while datetime <= interval.last_datetime
20
+ datetimes << datetime
21
+ datetime = datetime + frequency.duration
22
+ end
23
+ datetimes
24
+ end
25
+ end
26
+
27
+ def ranges
28
+ intervals.map { |i| (i.first_datetime..i.last_datetime) }
29
+ end
30
+
31
+ def first_datetime
32
+ surrounding_interval.first_datetime
33
+ end
34
+
35
+ def last_datetime
36
+ surrounding_interval.last_datetime
37
+ end
38
+
39
+ def surrounding_interval
40
+ Interval.surrounding(intervals)
41
+ end
42
+ alias_method :interval, :surrounding_interval # backwards compatibility
43
+
44
+ def match?(datetimes)
45
+ datetimes = Array.wrap(datetimes).map(&:to_datetime)
46
+ intervals.each do |interval|
47
+ current_datetime = interval.first_datetime
48
+ while current_datetime <= interval.last_datetime
49
+ datetimes.delete_if { |datetime| datetime == current_datetime }
50
+ return true if datetimes.empty?
51
+ current_datetime = current_datetime + frequency.duration
52
+ end
53
+ end
54
+ false
55
+ end
56
+
57
+ def <=>(other)
58
+ self.intervals.count <=> other.intervals.count
59
+ end
60
+
61
+ # Join with other IF same frequency AND same number of intervals
62
+ def join(other)
63
+ return nil unless self.frequency == other.frequency
64
+
65
+ expanded_datetimes = self.datetimes.map { |datetimes_within_an_interval|
66
+ back_one = datetimes_within_an_interval.first - frequency.duration
67
+ forward_one = datetimes_within_an_interval.last + frequency.duration
68
+
69
+ [back_one] + datetimes_within_an_interval + [forward_one]
70
+ }
71
+
72
+ joint_ranges = []
73
+
74
+ # Look for overlaps, where an overlap may be 'off by 1' -- hence the 'expanded_datetimes'
75
+ # ...but start with other and join to each of it's intervals.
76
+ #
77
+ # Remember that 'pattern.datetimes' returns a list of datetimes per interval
78
+ other.datetimes.each do |other_datetimes_within_an_interval|
79
+
80
+ joinable_datetimes = expanded_datetimes.find { |expanded_datetimes_within_an_interval|
81
+ other_datetimes_within_an_interval.any? { |d|
82
+ expanded_datetimes_within_an_interval.include?(d)
83
+ }
84
+ }
85
+ break unless joinable_datetimes
86
+
87
+ # Joint ranges should be those that overlap
88
+ #
89
+ # This is buggy, because joinable_datetimes is a list of datetimes per interval that overlap
90
+ # Excluding the first doesn't make sense
91
+ #
92
+ # Instead, we should exclude the first AND last for each element within joinable_datetimes
93
+ joint_datetimes = (other_datetimes_within_an_interval + joinable_datetimes[1...-1]).sort
94
+ joint_ranges << (joint_datetimes.first..joint_datetimes.last)
95
+ end
96
+
97
+ # This seems to be trying to say "Only join when we got one for each interval of self"
98
+ # ...it also seems too restrictive...
99
+ #
100
+ # What if other includes multiple intervals of self?
101
+ # Then we don't need same number of intervals
102
+ #
103
+ # Also might be wrong in other ways, it's tricky to tell
104
+ if joint_ranges.size == self.intervals.size
105
+ Pattern.new(joint_ranges, frequency.duration)
106
+ end
107
+ end
108
+
109
+ def to_s
110
+ single_date_intervals, multiple_dates_intervals = intervals.partition { |i| i.first_datetime == i.last_datetime}
111
+ patterns_strings = if multiple_dates_intervals.empty?
112
+ single_date_intervals.map(&:to_s)
113
+ else
114
+ interval_surrounding_multiple_dates = Interval.surrounding(multiple_dates_intervals)
115
+
116
+ multiple_dates_intervals_string = case frequency.unit
117
+ when :years
118
+ "every #{multiple_dates_intervals.map { |i| "#{i.first_datetime.day.ordinalize} of #{i.first_datetime.strftime('%B')}" }.uniq.to_sentence} #{interval_surrounding_multiple_dates}"
119
+ when :months
120
+ "every #{multiple_dates_intervals.map { |i| i.first_datetime.day.ordinalize }.uniq.to_sentence} of the month #{interval_surrounding_multiple_dates}"
121
+ when :weeks
122
+ weekdays = multiple_dates_intervals.map { |i| i.first_datetime.strftime('%A') }.uniq
123
+ if weekdays.count == 7
124
+ "every day #{interval_surrounding_multiple_dates}"
125
+ else
126
+ "every #{weekdays.to_sentence} #{interval_surrounding_multiple_dates}"
127
+ end
128
+ when :days
129
+ if multiple_dates_intervals.any? { |i| i.first_datetime != i.first_datetime.beginning_of_day }
130
+ "every day at #{multiple_dates_intervals.map { |i| i.first_datetime.strftime("%I:%M %p") }.to_sentence} #{interval_surrounding_multiple_dates}"
131
+ else
132
+ "every day #{interval_surrounding_multiple_dates}"
133
+ end
134
+ else
135
+ "#{frequency} #{multiple_dates_intervals.map(&:to_s).to_sentence}"
136
+ end
137
+ [multiple_dates_intervals_string] + single_date_intervals.map(&:to_s)
138
+ end
139
+ patterns_strings.to_sentence
140
+ end
141
+
142
+ private
143
+
144
+ # Fix the time units inconsistency problem
145
+ # 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
146
+ def fix_frequency
147
+ return unless frequency.duration > 1.month
148
+ if frequency.duration < 12.months
149
+ if intervals.all? { |i| i.first_datetime.day == i.last_datetime.day }
150
+ frequency.duration = frequency.units[:months].months
151
+ end
152
+ else
153
+ if intervals.all? { |i| i.first_datetime.month == i.last_datetime.month && i.first_datetime.day == i.last_datetime.day }
154
+ frequency.duration = (frequency.duration / 12.months).floor.years
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end