groupdate 4.1.2 → 6.0.1

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