lib/groupdate/series_builder.rb in groupdate-4.3.0 vs lib/groupdate/series_builder.rb in groupdate-5.0.0
- old
+ new
@@ -9,47 +9,79 @@
@time_zone = time_zone
@week_start = week_start
@day_start = day_start
@options = options
@round_time = {}
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
end
def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
series = generate_series(data, multiple_groups, group_index)
series = handle_multiple(data, series, multiple_groups, group_index)
+ verified_data = {}
+ series.each do |k|
+ verified_data[k] = data.delete(k)
+ end
+
+ # this is a fun one
+ # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
+ # Other databases and Active Support return the 1st hour (as expected)
+ # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
+ # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
+ # Ruby not good: Time.parse("2013-11-03 01:00:00")
+ # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
+ # we need to account for this here
+ if series_default && CHECK_PERIODS.include?(period)
+ data.each do |k, v|
+ key = multiple_groups ? k[group_index] : k
+ # TODO only do this for PostgreSQL
+ # this may mask some inconsistent time zone errors
+ # but not sure there's a better approach
+ if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
+ key -= 1.hour
+ if multiple_groups
+ k[group_index] = key
+ else
+ k = key
+ end
+ verified_data[k] = v
+ elsif key != round_time(key)
+ # only need to show what database returned since it will cast in Ruby time zone
+ raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
+ end
+ end
+ end
+
unless entire_series?(series_default)
- series = series.select { |k| data[k] }
+ series = series.select { |k| verified_data[k] }
end
value = 0
result = Hash[series.map do |k|
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
key =
if multiple_groups
k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
else
key_format.call(k)
end
[key, value]
end]
- # only check for database
- # only checks remaining keys to avoid expensive calls to round_time
- if series_default && CHECK_PERIODS.include?(period)
- check_consistent_time_zone_info(data, multiple_groups, group_index)
- end
-
result
end
def round_time(time)
time = time.to_time.in_time_zone(time_zone)
- # only if day_start != 0 for performance
- time -= day_start.seconds if day_start != 0
+ if day_start != 0
+ # apply day_start to a time object that's not affected by DST
+ time = change_zone.call(time, utc)
+ time -= day_start.seconds
+ end
time =
case period
when :second
time.change(usec: 0)
@@ -58,13 +90,11 @@
when :hour
time.change(min: 0)
when :day
time.beginning_of_day
when :week
- # same logic as MySQL group
- weekday = (time.wday - 1) % 7
- (time - ((7 - week_start + weekday) % 7).days).midnight
+ time.beginning_of_week(@week_start_key)
when :month
time.beginning_of_month
when :quarter
time.beginning_of_quarter
when :year
@@ -72,36 +102,48 @@
when :hour_of_day
time.hour
when :minute_of_hour
time.min
when :day_of_week
- (time.wday - 1 - week_start) % 7
+ time.days_to_week_start(@week_start_key)
when :day_of_month
time.day
when :month_of_year
time.month
when :day_of_year
time.yday
else
raise Groupdate::Error, "Invalid period"
end
- # only if day_start != 0 for performance
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
+ if day_start != 0 && time.is_a?(Time)
+ time += day_start.seconds
+ time = change_zone.call(time, time_zone)
+ end
time
end
+ def change_zone
+ @change_zone ||= begin
+ if ActiveSupport::VERSION::STRING >= "5.2"
+ ->(time, zone) { time.change(zone: zone) }
+ else
+ # TODO make more efficient
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
+ end
+ end
+ end
+
def time_range
@time_range ||= begin
time_range = options[:range]
if time_range.is_a?(Range) && time_range.first.is_a?(Date)
# convert range of dates to range of times
- # use parsing instead of in_time_zone due to Rails < 4
- last = time_zone.parse(time_range.last.to_s)
+ last = time_range.last.in_time_zone(time_zone)
last += 1.day unless time_range.exclude_end?
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
+ time_range = Range.new(time_range.first.in_time_zone(time_zone), last, true)
elsif !time_range && options[:last]
if period == :quarter
step = 3.months
elsif 1.respond_to?(period)
step = 1.send(period)
@@ -117,11 +159,12 @@
time_range =
if options[:current] == false
round_time(start_at - step)...round_time(now)
else
- round_time(start_at)..now
+ # extend to end of current period
+ round_time(start_at)...(round_time(now) + step)
end
end
end
time_range
end
@@ -197,38 +240,40 @@
end
end
end
def key_format
- locale = options[:locale] || I18n.locale
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
+ @key_format ||= begin
+ locale = options[:locale] || I18n.locale
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
- if options[:format]
- if options[:format].respond_to?(:call)
- options[:format]
- else
- sunday = time_zone.parse("2014-03-02 00:00:00")
- lambda do |key|
- case period
- when :hour_of_day
- key = sunday + key.hours + day_start.seconds
- when :minute_of_hour
- key = sunday + key.minutes + day_start.seconds
- when :day_of_week
- key = sunday + key.days + (week_start + 1).days
- when :day_of_month
- key = Date.new(2014, 1, key).to_time
- when :month_of_year
- key = Date.new(2014, key, 1).to_time
+ if options[:format]
+ if options[:format].respond_to?(:call)
+ options[:format]
+ else
+ sunday = time_zone.parse("2014-03-02 00:00:00")
+ lambda do |key|
+ case period
+ when :hour_of_day
+ key = sunday + key.hours + day_start.seconds
+ when :minute_of_hour
+ key = sunday + key.minutes + day_start.seconds
+ when :day_of_week
+ key = sunday + key.days + (week_start + 1).days
+ when :day_of_month
+ key = Date.new(2014, 1, key).to_time
+ when :month_of_year
+ key = Date.new(2014, key, 1).to_time
+ end
+ I18n.localize(key, format: options[:format], locale: locale)
end
- I18n.localize(key, format: options[:format], locale: locale)
end
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
+ lambda { |k| k.to_date }
+ else
+ lambda { |k| k }
end
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
- lambda { |k| k.to_date }
- else
- lambda { |k| k }
end
end
def handle_multiple(data, series, multiple_groups, group_index)
reverse = options[:reverse]
@@ -244,25 +289,14 @@
else
series
end
end
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
- keys = data.keys
- if multiple_groups
- keys.map! { |k| k[group_index] }
- keys.uniq!
- end
-
- keys.each do |key|
- if key != round_time(key)
- # only need to show what database returned since it will cast in Ruby time zone
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
- end
- end
- end
-
def entire_series?(series_default)
options.key?(:series) ? options[:series] : series_default
+ end
+
+ def utc
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
end
end
end