timely 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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