groupdate 4.3.0 → 5.2.2

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 00:00:00 UTC', #{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 00:00:00 UTC"
36
+ when :month
37
+ "%Y-%m-01 00:00:00 UTC"
38
+ when :quarter
39
+ raise Groupdate::Error, "Quarter not supported for SQLite"
40
+ else # year
41
+ "%Y-01-01 00:00:00 UTC"
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
@@ -2,11 +2,10 @@ module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
3
  define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- # TODO throw error in Groupdate 5
6
- warn "[groupdate] positional arguments are deprecated" if args.any?
7
- Groupdate::Magic::Enumerable.group_by(self, period, (args[0] || {}).merge(options), &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)
8
7
  elsif respond_to?(:scoping)
9
- scoping { @klass.send(:"group_by_#{period}", *args, **options, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
10
9
  else
11
10
  raise ArgumentError, "no block given"
12
11
  end
@@ -15,18 +14,12 @@ module Enumerable
15
14
 
16
15
  def group_by_period(period, *args, **options, &block)
17
16
  if block || !respond_to?(:scoping)
18
- # TODO throw error in Groupdate 5
19
- warn "[groupdate] positional arguments are deprecated" if args.any?
20
- options = (args[0] || {}).merge(options)
21
- # to_sym is unsafe on user input, so convert to strings
22
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
23
- if permitted_periods.include?(period.to_s)
24
- send("group_by_#{period}", **options, &block)
25
- else
26
- raise ArgumentError, "Unpermitted period"
27
- end
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
+
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
28
21
  else
29
- scoping { @klass.send(:group_by_period, period, *args, **options, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
30
23
  end
31
24
  end
32
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, :dates, :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,10 @@ 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, :day_of_year, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
92
148
  else
93
149
  utc = ActiveSupport::TimeZone["UTC"]
94
150
  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 +189,21 @@ module Groupdate
133
189
  def self.generate_relation(relation, field:, **options)
134
190
  magic = Groupdate::Magic::Relation.new(**options)
135
191
 
192
+ adapter_name = relation.connection.adapter_name
193
+ adapter = Groupdate.adapters[adapter_name]
194
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
195
+
136
196
  # generate ActiveRecord relation
137
197
  relation =
138
- RelationBuilder.new(
198
+ adapter.new(
139
199
  relation,
140
200
  column: field,
141
201
  period: magic.period,
142
202
  time_zone: magic.time_zone,
143
203
  time_range: magic.time_range,
144
204
  week_start: magic.week_start,
145
- day_start: magic.day_start
205
+ day_start: magic.day_start,
206
+ n_seconds: magic.n_seconds
146
207
  ).generate
147
208
 
148
209
  # add Groupdate info
@@ -1,27 +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|
5
- warn "[groupdate] positional arguments for time zone and range are deprecated" if time_zone || range
6
-
4
+ define_method :"group_by_#{period}" do |field, **options|
7
5
  Groupdate::Magic::Relation.generate_relation(self,
8
6
  period: period,
9
7
  field: field,
10
- time_zone: time_zone,
11
- range: range,
12
8
  **options
13
9
  )
14
10
  end
15
11
  end
16
12
 
17
13
  def group_by_period(period, field, permit: nil, **options)
18
- # to_sym is unsafe on user input, so convert to strings
19
- permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
20
- if permitted_periods.include?(period.to_s)
21
- send("group_by_#{period}", field, **options)
22
- else
23
- raise ArgumentError, "Unpermitted period"
24
- end
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
25
16
  end
26
17
  end
27
18
  end
@@ -1,29 +1,64 @@
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
5
  CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
6
 
7
- def initialize(period:, time_zone:, day_start:, week_start:, **options)
7
+ def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
8
  @period = period
9
9
  @time_zone = time_zone
10
10
  @week_start = week_start
11
11
  @day_start = day_start
12
+ @n_seconds = n_seconds
12
13
  @options = options
13
- @round_time = {}
14
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
14
15
  end
15
16
 
16
17
  def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
18
  series = generate_series(data, multiple_groups, group_index)
18
19
  series = handle_multiple(data, series, multiple_groups, group_index)
19
20
 
21
+ verified_data = {}
22
+ series.each do |k|
23
+ verified_data[k] = data.delete(k)
24
+ end
25
+
26
+ # this is a fun one
27
+ # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
28
+ # Other databases and Active Support return the 1st hour (as expected)
29
+ # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
30
+ # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
31
+ # Ruby not good: Time.parse("2013-11-03 01:00:00")
32
+ # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
33
+ # we need to account for this here
34
+ if series_default && CHECK_PERIODS.include?(period)
35
+ data.each do |k, v|
36
+ key = multiple_groups ? k[group_index] : k
37
+ # TODO only do this for PostgreSQL
38
+ # this may mask some inconsistent time zone errors
39
+ # but not sure there's a better approach
40
+ if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
41
+ key -= 1.hour
42
+ if multiple_groups
43
+ k[group_index] = key
44
+ else
45
+ k = key
46
+ end
47
+ verified_data[k] = v
48
+ elsif key != round_time(key)
49
+ # only need to show what database returned since it will cast in Ruby time zone
50
+ raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
51
+ end
52
+ end
53
+ end
54
+
20
55
  unless entire_series?(series_default)
21
- series = series.select { |k| data[k] }
56
+ series = series.select { |k| verified_data[k] }
22
57
  end
23
58
 
24
59
  value = 0
25
60
  result = Hash[series.map do |k|
26
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
61
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
27
62
  key =
28
63
  if multiple_groups
29
64
  k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
@@ -34,20 +69,21 @@ module Groupdate
34
69
  [key, value]
35
70
  end]
36
71
 
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
- end
42
-
43
72
  result
44
73
  end
45
74
 
46
75
  def round_time(time)
76
+ if period == :custom
77
+ return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
78
+ end
79
+
47
80
  time = time.to_time.in_time_zone(time_zone)
48
81
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
82
+ if day_start != 0
83
+ # apply day_start to a time object that's not affected by DST
84
+ time = change_zone.call(time, utc)
85
+ time -= day_start.seconds
86
+ end
51
87
 
52
88
  time =
53
89
  case period
@@ -60,9 +96,7 @@ module Groupdate
60
96
  when :day
61
97
  time.beginning_of_day
62
98
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
99
+ time.beginning_of_week(@week_start_key)
66
100
  when :month
67
101
  time.beginning_of_month
68
102
  when :quarter
@@ -74,7 +108,7 @@ module Groupdate
74
108
  when :minute_of_hour
75
109
  time.min
76
110
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
111
+ time.days_to_week_start(@week_start_key)
78
112
  when :day_of_month
79
113
  time.day
80
114
  when :month_of_year
@@ -85,24 +119,62 @@ module Groupdate
85
119
  raise Groupdate::Error, "Invalid period"
86
120
  end
87
121
 
88
- # only if day_start != 0 for performance
89
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
122
+ if day_start != 0 && time.is_a?(Time)
123
+ time += day_start.seconds
124
+ time = change_zone.call(time, time_zone)
125
+ end
90
126
 
91
127
  time
92
128
  end
93
129
 
130
+ def change_zone
131
+ @change_zone ||= begin
132
+ if ActiveSupport::VERSION::STRING >= "5.2"
133
+ ->(time, zone) { time.change(zone: zone) }
134
+ else
135
+ # TODO make more efficient
136
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
137
+ end
138
+ end
139
+ end
140
+
94
141
  def time_range
95
142
  @time_range ||= begin
96
143
  time_range = options[:range]
97
- if time_range.is_a?(Range) && time_range.first.is_a?(Date)
98
- # convert range of dates to range of times
99
- # use parsing instead of in_time_zone due to Rails < 4
100
- last = time_zone.parse(time_range.last.to_s)
101
- last += 1.day unless time_range.exclude_end?
102
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
144
+
145
+ if time_range.is_a?(Range)
146
+ # check types
147
+ [time_range.begin, time_range.end].each do |v|
148
+ case v
149
+ when nil, Date, Time
150
+ # good
151
+ when String
152
+ # TODO raise error in Groupdate 6
153
+ warn "[groupdate] Range bounds should be Date or Time, not #{v.class.name}. This will raise an error in Groupdate 6"
154
+ break
155
+ else
156
+ raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
157
+ end
158
+ end
159
+
160
+ start = time_range.begin
161
+ start = start.in_time_zone(time_zone) if start
162
+
163
+ exclude_end = time_range.exclude_end?
164
+
165
+ finish = time_range.end
166
+ finish = finish.in_time_zone(time_zone) if finish
167
+ if time_range.end.is_a?(Date) && !exclude_end
168
+ finish += 1.day
169
+ exclude_end = true
170
+ end
171
+
172
+ time_range = Range.new(start, finish, exclude_end)
103
173
  elsif !time_range && options[:last]
104
174
  if period == :quarter
105
175
  step = 3.months
176
+ elsif period == :custom
177
+ step = n_seconds
106
178
  elsif 1.respond_to?(period)
107
179
  step = 1.send(period)
108
180
  else
@@ -119,7 +191,8 @@ module Groupdate
119
191
  if options[:current] == false
120
192
  round_time(start_at - step)...round_time(now)
121
193
  else
122
- round_time(start_at)..now
194
+ # extend to end of current period
195
+ round_time(start_at)...(round_time(now) + step)
123
196
  end
124
197
  end
125
198
  end
@@ -150,7 +223,7 @@ module Groupdate
150
223
  else
151
224
  time_range = self.time_range
152
225
  time_range =
153
- if time_range.is_a?(Range)
226
+ if time_range.is_a?(Range) && time_range.begin && time_range.end
154
227
  time_range
155
228
  else
156
229
  # use first and last values
@@ -161,11 +234,23 @@ module Groupdate
161
234
  data.keys.sort
162
235
  end
163
236
 
164
- tr = sorted_keys.first..sorted_keys.last
165
- if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
166
- tr = tr.first...round_time(now)
237
+ if time_range.is_a?(Range)
238
+ if sorted_keys.any?
239
+ if time_range.begin
240
+ time_range.begin..sorted_keys.last
241
+ else
242
+ Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
243
+ end
244
+ else
245
+ nil..nil
246
+ end
247
+ else
248
+ tr = sorted_keys.first..sorted_keys.last
249
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
250
+ tr = tr.first...round_time(now)
251
+ end
252
+ tr
167
253
  end
168
- tr
169
254
  end
170
255
 
171
256
  if time_range.begin
@@ -173,14 +258,17 @@ module Groupdate
173
258
 
174
259
  if period == :quarter
175
260
  step = 3.months
261
+ elsif period == :custom
262
+ step = n_seconds
176
263
  else
177
264
  step = 1.send(period)
178
265
  end
179
266
 
180
267
  last_step = series.last
268
+ day_start_hour = day_start / 3600
181
269
  loop do
182
270
  next_step = last_step + step
183
- next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
271
+ next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
184
272
  break unless time_range.cover?(next_step)
185
273
 
186
274
  if next_step == last_step
@@ -199,34 +287,36 @@ module Groupdate
199
287
  end
200
288
 
201
289
  def key_format
202
- locale = options[:locale] || I18n.locale
203
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
290
+ @key_format ||= begin
291
+ locale = options[:locale] || I18n.locale
292
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
204
293
 
205
- if options[:format]
206
- if options[:format].respond_to?(:call)
207
- options[:format]
208
- else
209
- sunday = time_zone.parse("2014-03-02 00:00:00")
210
- lambda do |key|
211
- case period
212
- when :hour_of_day
213
- key = sunday + key.hours + day_start.seconds
214
- when :minute_of_hour
215
- key = sunday + key.minutes + day_start.seconds
216
- when :day_of_week
217
- key = sunday + key.days + (week_start + 1).days
218
- when :day_of_month
219
- key = Date.new(2014, 1, key).to_time
220
- when :month_of_year
221
- key = Date.new(2014, key, 1).to_time
294
+ if options[:format]
295
+ if options[:format].respond_to?(:call)
296
+ options[:format]
297
+ else
298
+ sunday = time_zone.parse("2014-03-02 00:00:00")
299
+ lambda do |key|
300
+ case period
301
+ when :hour_of_day
302
+ key = sunday + key.hours + day_start.seconds
303
+ when :minute_of_hour
304
+ key = sunday + key.minutes + day_start.seconds
305
+ when :day_of_week
306
+ key = sunday + key.days + (week_start + 1).days
307
+ when :day_of_month
308
+ key = Date.new(2014, 1, key).to_time
309
+ when :month_of_year
310
+ key = Date.new(2014, key, 1).to_time
311
+ end
312
+ I18n.localize(key, format: options[:format], locale: locale)
222
313
  end
223
- I18n.localize(key, format: options[:format], locale: locale)
224
314
  end
315
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
316
+ lambda { |k| k.to_date }
317
+ else
318
+ lambda { |k| k }
225
319
  end
226
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
227
- lambda { |k| k.to_date }
228
- else
229
- lambda { |k| k }
230
320
  end
231
321
  end
232
322
 
@@ -246,23 +336,12 @@ module Groupdate
246
336
  end
247
337
  end
248
338
 
249
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
250
- keys = data.keys
251
- if multiple_groups
252
- keys.map! { |k| k[group_index] }
253
- keys.uniq!
254
- end
255
-
256
- keys.each do |key|
257
- if key != round_time(key)
258
- # only need to show what database returned since it will cast in Ruby time zone
259
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
260
- end
261
- end
262
- end
263
-
264
339
  def entire_series?(series_default)
265
340
  options.key?(:series) ? options[:series] : series_default
266
341
  end
342
+
343
+ def utc
344
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
345
+ end
267
346
  end
268
347
  end