dotiw 5.3.3 → 5.6.0

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.
data/lib/dotiw/methods.rb CHANGED
@@ -44,6 +44,31 @@ module DOTIW
44
44
 
45
45
  private
46
46
 
47
+ # How many of each measure is necessary to round up to one of the next-largest measure. Note
48
+ # that for simplicity of implementation, we only check one measure when seeing if we should
49
+ # round up. This means that rounding up days to weeks, for instance, cannot draw the line at 3
50
+ # days and 12 hours, but instead either 3 or 4 (whole) days. Let's not even talk about weeks
51
+ # rounding up to months.
52
+ ROUNDING_THRESHOLDS = {
53
+ seconds: 30,
54
+ minutes: 30,
55
+ hours: 12,
56
+ days: 4,
57
+ weeks: 2,
58
+ months: 6,
59
+ years: Float::INFINITY,
60
+ }
61
+
62
+ ROLLUP_THRESHOLDS = {
63
+ seconds: 60,
64
+ minutes: 60,
65
+ hours: 24,
66
+ days: 7,
67
+ weeks: 4, # !!!
68
+ months: 12,
69
+ years: Float::INFINITY,
70
+ }
71
+
47
72
  def normalize_distance_of_time_argument_to_time(value)
48
73
  if value.is_a?(Numeric)
49
74
  Time.at(value)
@@ -63,47 +88,72 @@ module DOTIW
63
88
  end
64
89
 
65
90
  def _display_time_in_words(hash, options = {})
91
+ hash = hash.dup
92
+
66
93
  options = options.reverse_merge(
67
94
  include_seconds: false
68
95
  ).symbolize_keys!
69
96
 
97
+ discarded_hash = {}
98
+
70
99
  include_seconds = options.delete(:include_seconds)
71
- hash.delete(:seconds) if !include_seconds && hash[:minutes]
100
+ discarded_hash[:seconds] = hash.delete(:seconds) if !include_seconds && hash[:minutes]
72
101
 
73
102
  options[:except] = Array.wrap(options[:except]).map!(&:to_sym) if options[:except]
74
103
  options[:only] = Array.wrap(options[:only]).map!(&:to_sym) if options[:only]
75
104
 
76
- # Remove all the values that are nil or excluded. Keep the required ones.
77
- hash.delete_if do |key, value|
78
- value.nil? || value.zero? ||
79
- options[:except]&.include?(key) ||
80
- (options[:only] && !options[:only].include?(key))
105
+ if options[:except] || options[:only]
106
+ # Fold excluded fractions into the next smaller fraction instead of discarding their
107
+ # duration outright, e.g. weeks excluded via only: [:years, :months, :days] are converted
108
+ # to days and added to the days count, rather than losing those weeks entirely. Larger
109
+ # fractions are processed first so that a run of consecutive excluded fractions cascades
110
+ # down to the largest fraction that's still included (or is dropped if none remain).
111
+ DOTIW::TimeHash::TIME_FRACTIONS.reverse_each do |fraction|
112
+ next unless hash.key?(fraction)
113
+ next unless options[:except]&.include?(fraction) || (options[:only] && !options[:only].include?(fraction))
114
+
115
+ value = hash.delete(fraction)
116
+ discarded_hash[fraction] = value
117
+
118
+ next_fraction = _next_smaller_fraction(fraction)
119
+ next unless next_fraction
120
+
121
+ hash[next_fraction] = hash.fetch(next_fraction, 0) + (value * ROLLUP_THRESHOLDS[next_fraction])
122
+ end
81
123
  end
82
124
 
125
+ hash.delete_if { |key, value| value.nil? || value.zero? }
126
+ discarded_hash.delete_if { |key, value| value.nil? || value.zero? }
127
+
83
128
  i18n_scope = options.delete(:scope) || DOTIW::DEFAULT_I18N_SCOPE
84
129
  if hash.empty?
85
130
  fractions = DOTIW::TimeHash::TIME_FRACTIONS
86
131
  fractions &= options[:only] if options[:only]
87
132
  fractions -= options[:except] if options[:except]
88
133
 
89
- I18n.with_options locale: options[:locale], scope: i18n_scope do |locale|
90
- # e.g. try to format 'less than 1 days', fallback to '0 days'
91
- return locale.translate :less_than_x,
92
- distance: locale.translate(fractions.first, count: 1),
93
- default: locale.translate(fractions.first, count: 0)
94
- end
95
- end
96
-
97
- output = []
98
- I18n.with_options locale: options[:locale], scope: i18n_scope do |locale|
99
- output = hash.map { |key, value| locale.t(key, count: value) }
134
+ locale = options[:locale]
135
+ # e.g. try to format 'less than 1 days', fallback to '0 days'
136
+ return I18n.translate :less_than_x,
137
+ locale: locale,
138
+ scope: i18n_scope,
139
+ distance: I18n.translate(fractions.first, locale: locale, scope: i18n_scope, count: 1),
140
+ default: I18n.translate(fractions.first, locale: locale, scope: i18n_scope, count: 0)
100
141
  end
101
142
 
102
143
  options.delete(:except)
103
144
  options.delete(:only)
104
- highest_measures = options.delete(:highest_measures)
105
- highest_measures = 1 if options.delete(:highest_measure_only)
106
- output = output[0...highest_measures] if highest_measures
145
+
146
+ highest_measures = _compute_highest_measures! options
147
+ if highest_measures
148
+ high_entries, low_entries = hash.to_a.partition.with_index { |_, index| index < highest_measures[:max] }
149
+ hash = high_entries.to_h
150
+ discarded_hash.merge! low_entries.to_h
151
+
152
+ _maybe_round! hash, discarded_hash, highest_measures[:remainder]
153
+ end
154
+
155
+ locale = options[:locale]
156
+ phrases = hash.map { |key, value| I18n.translate(key, locale: locale, scope: i18n_scope, count: value) }
107
157
 
108
158
  options[:words_connector] ||= I18n.translate :"#{i18n_scope}.words_connector",
109
159
  default: :'support.array.words_connector',
@@ -115,7 +165,63 @@ module DOTIW
115
165
  default: :'support.array.last_word_connector',
116
166
  locale: options[:locale]
117
167
 
118
- output.to_sentence(options.except(:accumulate_on, :compact))
168
+ phrases.to_sentence(options.except(:accumulate_on, :max_unit, :compact))
169
+ end
170
+
171
+ def _compute_highest_measures!(options)
172
+ highest_measures = options.delete(:highest_measures)
173
+ highest_measures = 1 if options.delete(:highest_measure_only)
174
+ highest_measures = { max: highest_measures } if highest_measures.is_a?(Integer)
175
+ highest_measures = highest_measures.reverse_merge(max: 1, remainder: :floor) if highest_measures
176
+
177
+ highest_measures
178
+ end
179
+
180
+ def _maybe_round!(hash, discarded_hash, remainder)
181
+ smallest_measure_index = DOTIW::TimeHash::TIME_FRACTIONS.index hash.to_a.last[0]
182
+ smallest_measure = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index]
183
+
184
+ case remainder
185
+ when :floor
186
+ # Nothing to do.
187
+ when :ceiling
188
+ # We already filtered out zeroes, so non-empty also means non-zero.
189
+ if !discarded_hash.empty?
190
+ hash[smallest_measure] += 1
191
+ _rollup! hash, smallest_measure_index
192
+ end
193
+ when :round
194
+ # If our smallest measure is already the smallest possible measure, there is no next
195
+ # smallest measure to inspect to see if we need to round up.
196
+ return if smallest_measure_index == 0
197
+
198
+ next_smallest_measure = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index - 1]
199
+ if discarded_hash.fetch(next_smallest_measure, 0) >= ROUNDING_THRESHOLDS[next_smallest_measure]
200
+ hash[smallest_measure] += 1
201
+ _rollup! hash, smallest_measure_index
202
+ end
203
+ else
204
+ raise ArgumentError, "unrecognized remainder value #{remainder.inspect}"
205
+ end
206
+ end
207
+
208
+ def _next_smaller_fraction(fraction)
209
+ index = DOTIW::TimeHash::TIME_FRACTIONS.index(fraction)
210
+ return nil if index.nil? || index.zero?
211
+
212
+ DOTIW::TimeHash::TIME_FRACTIONS[index - 1]
213
+ end
214
+
215
+ def _rollup!(hash, smallest_measure_index)
216
+ DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index..-1].each_with_index do |fraction, index|
217
+ if hash.fetch(fraction, 0) >= ROLLUP_THRESHOLDS[fraction]
218
+ hash.delete fraction
219
+ next_fraction = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index + index + 1]
220
+ hash[next_fraction] = hash.fetch(next_fraction, 0) + 1
221
+ else
222
+ break
223
+ end
224
+ end
119
225
  end
120
226
  end
121
227
  end
@@ -13,15 +13,9 @@ module DOTIW
13
13
  @from_time = from_time || Time.current
14
14
  @to_time = to_time || (@to_time_not_given = true && @from_time + distance.seconds)
15
15
  @smallest, @largest = [@from_time, @to_time].minmax
16
- @to_time += 1.hour if @to_time_not_given && smallest.dst? && !largest.dst?
17
- @to_time -= 1.hour if @to_time_not_given && !smallest.dst? && largest.dst?
16
+ @to_time += offset_delta(smallest, largest) if @to_time_not_given
18
17
  @smallest, @largest = [@from_time, @to_time].minmax
19
- @distance ||= begin
20
- d = largest - smallest
21
- d -= 1.hour if smallest.dst? && !largest.dst?
22
- d += 1.hour if !smallest.dst? && largest.dst?
23
- d
24
- end
18
+ @distance ||= (largest - smallest) + offset_delta(smallest, largest)
25
19
 
26
20
  build_time_hash
27
21
  end
@@ -40,11 +34,23 @@ module DOTIW
40
34
  ONE_WEEK = 7.days.freeze
41
35
  FOUR_WEEKS = 28.days.freeze
42
36
 
37
+ # The distance between two Time objects computed via subtraction already
38
+ # reflects any change in UTC offset between them (whether from a DST
39
+ # transition or a permanent tzdata rule change, e.g. Pacific/Norfolk's
40
+ # 2015 UTC offset change), which is what we want when reporting a raw
41
+ # elapsed duration. However, when we split that distance into calendar
42
+ # fields (years/months/weeks/days), we want the offset difference
43
+ # folded away so the leftover hours/minutes/seconds reflect only actual
44
+ # elapsed wall-clock time, not artifacts of an offset shift.
45
+ def offset_delta(smallest, largest)
46
+ largest.utc_offset - smallest.utc_offset
47
+ end
48
+
43
49
  def build_time_hash
44
- if accumulate_on = options[:accumulate_on]
50
+ if (max_unit = options[:max_unit])
51
+ build_max_unit(max_unit.to_sym)
52
+ elsif accumulate_on = options[:accumulate_on]
45
53
  accumulate_on = accumulate_on.to_sym
46
- return build_time_hash if accumulate_on == :years
47
-
48
54
  TIME_FRACTIONS.index(accumulate_on).downto(0) { |i| send("build_#{TIME_FRACTIONS[i]}") }
49
55
  else
50
56
  while distance > 0
@@ -88,14 +94,56 @@ module DOTIW
88
94
  output[:weeks], @distance = distance.divmod(ONE_WEEK.to_i) unless output[:weeks]
89
95
  end
90
96
 
97
+ # Expresses the entire distance as a single number in the given unit, discarding
98
+ # (flooring) any remainder smaller than that unit, unlike +accumulate_on+ which keeps
99
+ # showing the smaller units below the target.
100
+ def build_max_unit(unit)
101
+ case unit
102
+ when :seconds
103
+ output[:seconds] = distance.to_i
104
+ when :minutes
105
+ output[:minutes] = (distance / ONE_MINUTE).floor
106
+ when :hours
107
+ output[:hours] = (distance / ONE_HOUR).floor
108
+ when :days
109
+ output[:days] = (distance / ONE_DAY).floor
110
+ when :weeks
111
+ output[:weeks] = (distance / ONE_WEEK).floor
112
+ when :months
113
+ output[:months] = whole_months_between(smallest, largest)
114
+ when :years
115
+ output[:years] = whole_years_between(smallest, largest)
116
+ else
117
+ raise ArgumentError, "unrecognized max_unit #{unit.inspect}"
118
+ end
119
+
120
+ @distance = 0
121
+ end
122
+
123
+ def whole_months_between(from, to)
124
+ months = (to.year - from.year) * 12 + (to.month - from.month)
125
+ months -= 1 if to.advance(months: -months) < from
126
+ months
127
+ end
128
+
129
+ def whole_years_between(from, to)
130
+ years = to.year - from.year
131
+ years -= 1 if to.advance(years: -years) < from
132
+ years
133
+ end
134
+
91
135
  def build_months
92
136
  build_years_months_weeks_days
93
137
 
94
- if (years = output.delete(:years)) > 0
138
+ if options[:accumulate_on]&.to_sym == :months && (years = output.delete(:years)) > 0
95
139
  output[:months] += (years * 12)
96
140
  end
97
141
  end
98
142
 
143
+ def build_years
144
+ build_years_months_weeks_days
145
+ end
146
+
99
147
  def build_years_months_weeks_days
100
148
  months = (largest.year - smallest.year) * 12 + (largest.month - smallest.month)
101
149
  years, months = months.divmod(12)
data/lib/dotiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DOTIW
4
- VERSION = '5.3.3'
4
+ VERSION = '5.6.0'
5
5
  end
data/lib/dotiw.rb CHANGED
@@ -1,45 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n'
4
-
5
- require 'active_support'
6
- require 'active_support/core_ext'
7
-
8
- module DOTIW
9
- extend ActiveSupport::Autoload
10
-
11
- eager_autoload do
12
- autoload :VERSION, 'dotiw/version'
13
- autoload :TimeHash, 'dotiw/time_hash'
14
- autoload :Methods, 'dotiw/methods'
15
- end
16
-
17
- extend self
18
-
19
- DEFAULT_I18N_SCOPE = :'datetime.dotiw'
20
- DEFAULT_I18N_SCOPE_COMPACT = :'datetime.dotiw_compact'
21
-
22
- def init_i18n!
23
- I18n.load_path.unshift(*locale_files)
24
- I18n.reload!
25
- end
26
-
27
- def languages
28
- @languages ||= (locale_files.map { |path| path.split(%r{[/.]})[-2].to_sym })
29
- end
30
-
31
- def locale_files
32
- files 'dotiw/locale', '*.yml'
33
- end
34
-
35
- protected
36
-
37
- def files(directory, ext)
38
- Dir[File.join File.dirname(__FILE__), directory, ext]
39
- end
40
- end
41
-
42
- DOTIW.init_i18n!
3
+ require_relative 'dotiw/core'
43
4
 
44
5
  begin
45
6
  require 'action_view'
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe DOTIW do
6
+ START_TIME_MODULE = '01-08-2009'.to_time(:utc)
7
+
8
+ before do
9
+ I18n.locale = :en
10
+ end
11
+
12
+ describe '.distance_of_time_in_words' do
13
+ it 'returns the exact distance' do
14
+ expect(
15
+ described_class.distance_of_time_in_words(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
16
+ ).to eq('1 hour and 30 minutes')
17
+ end
18
+
19
+ it 'accepts options' do
20
+ expect(
21
+ described_class.distance_of_time_in_words(
22
+ START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes, highest_measures: 1
23
+ )
24
+ ).to eq('1 hour')
25
+ end
26
+ end
27
+
28
+ describe '.time_ago_in_words' do
29
+ it 'returns the distance to now' do
30
+ allow(Time).to receive(:now).and_return(START_TIME_MODULE)
31
+ allow(Time.zone).to receive(:now).and_return(START_TIME_MODULE)
32
+
33
+ expect(
34
+ described_class.time_ago_in_words(START_TIME_MODULE - 90, include_seconds: true)
35
+ ).to eq('1 minute and 30 seconds')
36
+ end
37
+ end
38
+
39
+ describe '.distance_of_time' do
40
+ it 'returns the distance for a number of seconds' do
41
+ expect(described_class.distance_of_time(90)).to eq('1 minute and 30 seconds')
42
+ end
43
+ end
44
+
45
+ describe '.distance_of_time_in_words_hash' do
46
+ it 'returns the distance as a hash' do
47
+ expect(
48
+ described_class.distance_of_time_in_words_hash(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
49
+ ).to include(hours: 1, minutes: 30)
50
+ end
51
+ end
52
+ end