lib/groupdate/series_builder.rb in groupdate2-4.1.5 vs lib/groupdate/series_builder.rb in groupdate2-5.0.0

- old
+ new

@@ -9,47 +9,85 @@ @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'); + # SQLServer good: SELECT CAST('2013-11-03 01:00:00' AS DATETIME2(3)) AT TIME ZONE 'Pacific Standard Time' AT TIME ZONE '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}: #{round_time(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 format_series_label(series_label) + series_label = round_time(series_label) if series_label.respond_to?(:to_time) + key_format.call(series_label) + 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 +96,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,34 +108,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) @@ -115,11 +165,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 @@ -139,10 +190,12 @@ 0..23 when :minute_of_hour 0..59 when :day_of_month 1..31 + when :day_of_year + 1..366 when :month_of_year 1..12 else time_range = self.time_range time_range = @@ -162,23 +215,24 @@ tr = tr.first...round_time(now) end tr end - if time_range.first - series = [round_time(time_range.first)] + if time_range.begin + series = [round_time(time_range.begin)] if period == :quarter step = 3.months else step = 1.send(period) end last_step = series.last + day_start_hour = day_start / 3600 loop do next_step = last_step + step - next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up + next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up break unless time_range.cover?(next_step) if next_step == last_step last_step += step next @@ -193,38 +247,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] @@ -240,25 +296,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} != #{round_time(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