groupdate 4.1.2 → 6.0.1

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.
@@ -0,0 +1,51 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class SQLiteAdapter < BaseAdapter
4
+ def group_clause
5
+ raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
6
+ raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
7
+
8
+ query =
9
+ if period == :week
10
+ ["strftime('%Y-%m-%d', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
11
+ elsif period == :custom
12
+ ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
13
+ else
14
+ format =
15
+ case period
16
+ when :minute_of_hour
17
+ "%M"
18
+ when :hour_of_day
19
+ "%H"
20
+ when :day_of_week
21
+ "%w"
22
+ when :day_of_month
23
+ "%d"
24
+ when :day_of_year
25
+ "%j"
26
+ when :month_of_year
27
+ "%m"
28
+ when :second
29
+ "%Y-%m-%d %H:%M:%S UTC"
30
+ when :minute
31
+ "%Y-%m-%d %H:%M:00 UTC"
32
+ when :hour
33
+ "%Y-%m-%d %H:00:00 UTC"
34
+ when :day
35
+ "%Y-%m-%d"
36
+ when :month
37
+ "%Y-%m-01"
38
+ when :quarter
39
+ raise Groupdate::Error, "Quarter not supported for SQLite"
40
+ else # year
41
+ "%Y-01-01"
42
+ end
43
+
44
+ ["strftime(?, #{column})", format]
45
+ end
46
+
47
+ @relation.send(:sanitize_sql_array, query)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,31 +1,25 @@
1
1
  module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
- define_method :"group_by_#{period}" do |*args, &block|
3
+ define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- Groupdate::Magic::Enumerable.group_by(self, period, args[0] || {}, &block)
5
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
+ Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
6
7
  elsif respond_to?(:scoping)
7
- scoping { @klass.send(:"group_by_#{period}", *args, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
8
9
  else
9
10
  raise ArgumentError, "no block given"
10
11
  end
11
12
  end
12
13
  end
13
14
 
14
- def group_by_period(*args, &block)
15
+ def group_by_period(period, *args, **options, &block)
15
16
  if block || !respond_to?(:scoping)
16
- period = args[0]
17
- options = args[1] || {}
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
18
 
19
- options = options.dup
20
- # to_sym is unsafe on user input, so convert to strings
21
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
22
- if permitted_periods.include?(period.to_s)
23
- send("group_by_#{period}", options, &block)
24
- else
25
- raise ArgumentError, "Unpermitted period"
26
- end
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
27
21
  else
28
- scoping { @klass.send(:group_by_period, *args, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
29
23
  end
30
24
  end
31
25
  end
@@ -2,18 +2,52 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
- attr_accessor :period, :options, :group_index
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
7
+ attr_accessor :period, :options, :group_index, :n_seconds
6
8
 
7
9
  def initialize(period:, **options)
8
10
  @period = period
9
11
  @options = options
10
12
 
11
- unknown_keywords = options.keys - [:day_start, :time_zone, :dates, :series, :week_start, :format, :locale, :range, :reverse]
13
+ validate_keywords
14
+ validate_arguments
15
+
16
+ if options[:n]
17
+ raise ArgumentError, "n must be a positive integer" if !options[:n].is_a?(Integer) || options[:n] < 1
18
+ @period = :custom
19
+ @n_seconds = options[:n].to_i
20
+ @n_seconds *= 60 if period == :minute
21
+ end
22
+ end
23
+
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :series, :format, :locale, :range, :reverse]
26
+
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
29
+ end
30
+
31
+ if %i[day week month quarter year day_of_week hour_of_day day_of_month day_of_year month_of_year].include?(period)
32
+ known_keywords << :day_start
33
+ else
34
+ # prevent Groupdate.day_start from applying
35
+ @day_start = 0
36
+ end
37
+
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
40
+ end
41
+
42
+ unknown_keywords = options.keys - known_keywords
12
43
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
44
+ end
13
45
 
14
- raise Groupdate::Error, "Unrecognized time zone" unless time_zone
15
- raise Groupdate::Error, "Unrecognized :week_start option" if period == :week && !week_start
16
- raise Groupdate::Error, "Cannot use endless range for :range option" if options[:range].is_a?(Range) && !options[:range].end
46
+ def validate_arguments
47
+ # TODO better messages
48
+ raise ArgumentError, "Unrecognized time zone" unless time_zone
49
+ raise ArgumentError, "Unrecognized :week_start option" unless week_start
50
+ raise ArgumentError, ":day_start must be between 0 and 24" if (day_start / 3600) < 0 || (day_start / 3600) >= 24
17
51
  end
18
52
 
19
53
  def time_zone
@@ -25,21 +59,38 @@ module Groupdate
25
59
  end
26
60
 
27
61
  def week_start
28
- @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
62
+ @week_start ||= begin
63
+ v = (options[:week_start] || Groupdate.week_start).to_sym
64
+ DAYS.index(v) || [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index(v)
65
+ end
29
66
  end
30
67
 
31
68
  def day_start
32
69
  @day_start ||= ((options[:day_start] || Groupdate.day_start).to_f * 3600).round
33
70
  end
34
71
 
72
+ def range
73
+ @range ||= begin
74
+ time_range = options[:range]
75
+
76
+ if time_range.is_a?(Range) && time_range.begin.nil? && time_range.end.nil?
77
+ nil
78
+ else
79
+ time_range
80
+ end
81
+ end
82
+ end
83
+
35
84
  def series_builder
36
85
  @series_builder ||=
37
86
  SeriesBuilder.new(
38
87
  **options,
39
88
  period: period,
40
89
  time_zone: time_zone,
90
+ range: range,
41
91
  day_start: day_start,
42
- week_start: week_start
92
+ week_start: week_start,
93
+ n_seconds: n_seconds
43
94
  )
44
95
  end
45
96
 
@@ -47,6 +98,11 @@ module Groupdate
47
98
  series_builder.time_range
48
99
  end
49
100
 
101
+ def self.validate_period(period, permit)
102
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
103
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
104
+ end
105
+
50
106
  class Enumerable < Magic
51
107
  def group_by(enum, &_block)
52
108
  group = enum.group_by do |v|
@@ -85,10 +141,20 @@ module Groupdate
85
141
  def cast_method
86
142
  @cast_method ||= begin
87
143
  case period
144
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
145
+ lambda { |k| k.to_i }
88
146
  when :day_of_week
89
147
  lambda { |k| (k.to_i - 1 - week_start) % 7 }
90
- when :hour_of_day, :day_of_month, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
148
+ when :day, :week, :month, :quarter, :year
149
+ # TODO keep as date
150
+ if day_start != 0
151
+ day_start_hour = day_start / 3600
152
+ day_start_min = (day_start % 3600) / 60
153
+ day_start_sec = (day_start % 3600) % 60
154
+ lambda { |k| k.in_time_zone(time_zone).change(hour: day_start_hour, min: day_start_min, sec: day_start_sec) }
155
+ else
156
+ lambda { |k| k.in_time_zone(time_zone) }
157
+ end
92
158
  else
93
159
  utc = ActiveSupport::TimeZone["UTC"]
94
160
  lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
@@ -133,16 +199,25 @@ module Groupdate
133
199
  def self.generate_relation(relation, field:, **options)
134
200
  magic = Groupdate::Magic::Relation.new(**options)
135
201
 
202
+ adapter_name = relation.connection.adapter_name
203
+ adapter = Groupdate.adapters[adapter_name]
204
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
205
+
206
+ # very important
207
+ column = validate_column(field)
208
+ column = resolve_column(relation, column)
209
+
136
210
  # generate ActiveRecord relation
137
211
  relation =
138
- RelationBuilder.new(
212
+ adapter.new(
139
213
  relation,
140
- column: field,
214
+ column: column,
141
215
  period: magic.period,
142
216
  time_zone: magic.time_zone,
143
217
  time_range: magic.time_range,
144
218
  week_start: magic.week_start,
145
- day_start: magic.day_start
219
+ day_start: magic.day_start,
220
+ n_seconds: magic.n_seconds
146
221
  ).generate
147
222
 
148
223
  # add Groupdate info
@@ -152,6 +227,30 @@ module Groupdate
152
227
  relation
153
228
  end
154
229
 
230
+ class << self
231
+ # basic version of Active Record disallow_raw_sql!
232
+ # symbol = column (safe), Arel node = SQL (safe), other = untrusted
233
+ # matches table.column and column
234
+ def validate_column(column)
235
+ unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
236
+ column = column.to_s
237
+ unless /\A\w+(\.\w+)?\z/i.match(column)
238
+ raise ActiveRecord::UnknownAttributeReference, "Query method called with non-attribute argument(s): #{column.inspect}. Use Arel.sql() for known-safe values."
239
+ end
240
+ end
241
+ column
242
+ end
243
+
244
+ # resolves eagerly
245
+ # need to convert both where_clause (easy)
246
+ # and group_clause (not easy) if want to avoid this
247
+ def resolve_column(relation, column)
248
+ node = relation.send(:relation).send(:arel_columns, [column]).first
249
+ node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
250
+ relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
251
+ end
252
+ end
253
+
155
254
  # allow any options to keep flexible for future
156
255
  def self.process_result(relation, result, **options)
157
256
  relation.groupdate_values.reverse.each do |gv|
@@ -1,25 +1,18 @@
1
1
  module Groupdate
2
2
  module QueryMethods
3
3
  Groupdate::PERIODS.each do |period|
4
- define_method :"group_by_#{period}" do |field, time_zone = nil, range = nil, **options|
4
+ define_method :"group_by_#{period}" do |field, **options|
5
5
  Groupdate::Magic::Relation.generate_relation(self,
6
6
  period: period,
7
7
  field: field,
8
- time_zone: time_zone,
9
- range: range,
10
8
  **options
11
9
  )
12
10
  end
13
11
  end
14
12
 
15
13
  def group_by_period(period, field, permit: nil, **options)
16
- # to_sym is unsafe on user input, so convert to strings
17
- permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
18
- if permitted_periods.include?(period.to_s)
19
- send("group_by_#{period}", field, **options)
20
- else
21
- raise ArgumentError, "Unpermitted period"
22
- end
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
23
16
  end
24
17
  end
25
18
  end
@@ -9,6 +9,9 @@ module Groupdate
9
9
  end
10
10
 
11
11
  def calculate(*args, &block)
12
+ # prevent calculate from being called twice
13
+ return super if ActiveRecord::VERSION::STRING.to_f >= 6.1 && has_include?(args[1])
14
+
12
15
  default_value = [:count, :sum].include?(args[0]) ? 0 : nil
13
16
  Groupdate.process_result(self, super, default_value: default_value)
14
17
  end
@@ -1,29 +1,33 @@
1
1
  module Groupdate
2
2
  class SeriesBuilder
3
- attr_reader :period, :time_zone, :day_start, :week_start, :options
3
+ attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
4
4
 
5
- CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
-
7
- def initialize(period:, time_zone:, day_start:, week_start:, **options)
5
+ def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
6
  @period = period
9
7
  @time_zone = time_zone
10
8
  @week_start = week_start
11
9
  @day_start = day_start
10
+ @n_seconds = n_seconds
12
11
  @options = options
13
- @round_time = {}
12
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
14
13
  end
15
14
 
16
15
  def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
16
  series = generate_series(data, multiple_groups, group_index)
18
17
  series = handle_multiple(data, series, multiple_groups, group_index)
19
18
 
19
+ verified_data = {}
20
+ series.each do |k|
21
+ verified_data[k] = data.delete(k)
22
+ end
23
+
20
24
  unless entire_series?(series_default)
21
- series = series.select { |k| data[k] }
25
+ series = series.select { |k| verified_data[k] }
22
26
  end
23
27
 
24
28
  value = 0
25
- result = Hash[series.map do |k|
26
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
29
+ result = series.to_h do |k|
30
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
27
31
  key =
28
32
  if multiple_groups
29
33
  k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
@@ -32,22 +36,23 @@ module Groupdate
32
36
  end
33
37
 
34
38
  [key, value]
35
- end]
36
-
37
- # only check for database
38
- # only checks remaining keys to avoid expensive calls to round_time
39
- if series_default && CHECK_PERIODS.include?(period)
40
- check_consistent_time_zone_info(data, multiple_groups, group_index)
41
39
  end
42
40
 
43
41
  result
44
42
  end
45
43
 
46
44
  def round_time(time)
45
+ if period == :custom
46
+ return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
47
+ end
48
+
47
49
  time = time.to_time.in_time_zone(time_zone)
48
50
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
51
+ if day_start != 0
52
+ # apply day_start to a time object that's not affected by DST
53
+ time = time.change(zone: utc)
54
+ time -= day_start.seconds
55
+ end
51
56
 
52
57
  time =
53
58
  case period
@@ -60,9 +65,7 @@ module Groupdate
60
65
  when :day
61
66
  time.beginning_of_day
62
67
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
68
+ time.beginning_of_week(@week_start_key)
66
69
  when :month
67
70
  time.beginning_of_month
68
71
  when :quarter
@@ -74,17 +77,21 @@ module Groupdate
74
77
  when :minute_of_hour
75
78
  time.min
76
79
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
80
+ time.days_to_week_start(@week_start_key)
78
81
  when :day_of_month
79
82
  time.day
80
83
  when :month_of_year
81
84
  time.month
85
+ when :day_of_year
86
+ time.yday
82
87
  else
83
88
  raise Groupdate::Error, "Invalid period"
84
89
  end
85
90
 
86
- # only if day_start != 0 for performance
87
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
91
+ if day_start != 0 && time.is_a?(Time)
92
+ time += day_start.seconds
93
+ time = time.change(zone: time_zone)
94
+ end
88
95
 
89
96
  time
90
97
  end
@@ -92,15 +99,36 @@ module Groupdate
92
99
  def time_range
93
100
  @time_range ||= begin
94
101
  time_range = options[:range]
95
- if time_range.is_a?(Range) && time_range.first.is_a?(Date)
96
- # convert range of dates to range of times
97
- # use parsing instead of in_time_zone due to Rails < 4
98
- last = time_zone.parse(time_range.last.to_s)
99
- last += 1.day unless time_range.exclude_end?
100
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
102
+
103
+ if time_range.is_a?(Range)
104
+ # check types
105
+ [time_range.begin, time_range.end].each do |v|
106
+ case v
107
+ when nil, Date, Time
108
+ # good
109
+ else
110
+ raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
111
+ end
112
+ end
113
+
114
+ start = time_range.begin
115
+ start = start.in_time_zone(time_zone) if start
116
+
117
+ exclude_end = time_range.exclude_end?
118
+
119
+ finish = time_range.end
120
+ finish = finish.in_time_zone(time_zone) if finish
121
+ if time_range.end.is_a?(Date) && !exclude_end
122
+ finish += 1.day
123
+ exclude_end = true
124
+ end
125
+
126
+ time_range = Range.new(start, finish, exclude_end)
101
127
  elsif !time_range && options[:last]
102
128
  if period == :quarter
103
129
  step = 3.months
130
+ elsif period == :custom
131
+ step = n_seconds
104
132
  elsif 1.respond_to?(period)
105
133
  step = 1.send(period)
106
134
  else
@@ -117,7 +145,8 @@ module Groupdate
117
145
  if options[:current] == false
118
146
  round_time(start_at - step)...round_time(now)
119
147
  else
120
- round_time(start_at)..now
148
+ # extend to end of current period
149
+ round_time(start_at)...(round_time(now) + step)
121
150
  end
122
151
  end
123
152
  end
@@ -141,12 +170,14 @@ module Groupdate
141
170
  0..59
142
171
  when :day_of_month
143
172
  1..31
173
+ when :day_of_year
174
+ 1..366
144
175
  when :month_of_year
145
176
  1..12
146
177
  else
147
178
  time_range = self.time_range
148
179
  time_range =
149
- if time_range.is_a?(Range)
180
+ if time_range.is_a?(Range) && time_range.begin && time_range.end
150
181
  time_range
151
182
  else
152
183
  # use first and last values
@@ -157,26 +188,41 @@ module Groupdate
157
188
  data.keys.sort
158
189
  end
159
190
 
160
- tr = sorted_keys.first..sorted_keys.last
161
- if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
162
- tr = tr.first...round_time(now)
191
+ if time_range.is_a?(Range)
192
+ if sorted_keys.any?
193
+ if time_range.begin
194
+ time_range.begin..sorted_keys.last
195
+ else
196
+ Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
197
+ end
198
+ else
199
+ nil..nil
200
+ end
201
+ else
202
+ tr = sorted_keys.first..sorted_keys.last
203
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
204
+ tr = tr.first...round_time(now)
205
+ end
206
+ tr
163
207
  end
164
- tr
165
208
  end
166
209
 
167
- if time_range.first
168
- series = [round_time(time_range.first)]
210
+ if time_range.begin
211
+ series = [round_time(time_range.begin)]
169
212
 
170
213
  if period == :quarter
171
214
  step = 3.months
215
+ elsif period == :custom
216
+ step = n_seconds
172
217
  else
173
218
  step = 1.send(period)
174
219
  end
175
220
 
176
221
  last_step = series.last
222
+ day_start_hour = day_start / 3600
177
223
  loop do
178
224
  next_step = last_step + step
179
- next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
225
+ next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
180
226
  break unless time_range.cover?(next_step)
181
227
 
182
228
  if next_step == last_step
@@ -195,34 +241,35 @@ module Groupdate
195
241
  end
196
242
 
197
243
  def key_format
198
- locale = options[:locale] || I18n.locale
199
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
244
+ @key_format ||= begin
245
+ locale = options[:locale] || I18n.locale
200
246
 
201
- if options[:format]
202
- if options[:format].respond_to?(:call)
203
- options[:format]
204
- else
205
- sunday = time_zone.parse("2014-03-02 00:00:00")
206
- lambda do |key|
207
- case period
208
- when :hour_of_day
209
- key = sunday + key.hours + day_start.seconds
210
- when :minute_of_hour
211
- key = sunday + key.minutes + day_start.seconds
212
- when :day_of_week
213
- key = sunday + key.days + (week_start + 1).days
214
- when :day_of_month
215
- key = Date.new(2014, 1, key).to_time
216
- when :month_of_year
217
- key = Date.new(2014, key, 1).to_time
247
+ if options[:format]
248
+ if options[:format].respond_to?(:call)
249
+ options[:format]
250
+ else
251
+ sunday = time_zone.parse("2014-03-02 00:00:00")
252
+ lambda do |key|
253
+ case period
254
+ when :hour_of_day
255
+ key = sunday + key.hours + day_start.seconds
256
+ when :minute_of_hour
257
+ key = sunday + key.minutes + day_start.seconds
258
+ when :day_of_week
259
+ key = sunday + key.days + (week_start + 1).days
260
+ when :day_of_month
261
+ key = Date.new(2014, 1, key).to_time
262
+ when :month_of_year
263
+ key = Date.new(2014, key, 1).to_time
264
+ end
265
+ I18n.localize(key, format: options[:format], locale: locale)
218
266
  end
219
- I18n.localize(key, format: options[:format], locale: locale)
220
267
  end
268
+ elsif [:day, :week, :month, :quarter, :year].include?(period)
269
+ lambda { |k| k.to_date }
270
+ else
271
+ lambda { |k| k }
221
272
  end
222
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
223
- lambda { |k| k.to_date }
224
- else
225
- lambda { |k| k }
226
273
  end
227
274
  end
228
275
 
@@ -242,23 +289,12 @@ module Groupdate
242
289
  end
243
290
  end
244
291
 
245
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
246
- keys = data.keys
247
- if multiple_groups
248
- keys.map! { |k| k[group_index] }
249
- keys.uniq!
250
- end
251
-
252
- keys.each do |key|
253
- if key != round_time(key)
254
- # only need to show what database returned since it will cast in Ruby time zone
255
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
256
- end
257
- end
258
- end
259
-
260
292
  def entire_series?(series_default)
261
293
  options.key?(:series) ? options[:series] : series_default
262
294
  end
295
+
296
+ def utc
297
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
298
+ end
263
299
  end
264
300
  end
@@ -1,3 +1,3 @@
1
1
  module Groupdate
2
- VERSION = "4.1.2"
2
+ VERSION = "6.0.1"
3
3
  end