dotiw 5.6.0 → 5.6.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b9778f6b8dfb3fa5013f9e365c91be2fcb955f040e19dde50d8f70b1fb28fcd
4
- data.tar.gz: 5213a53264dd238ea225939d53c40a63523749b92331ef4a899fa3ca190d5bce
3
+ metadata.gz: fa29e6fb27292383b6c52e90e638a79df342e512d2132fc43399fbdac2accce7
4
+ data.tar.gz: a2a5f6a9f954ffa985ecb48a5888f860191be892e451f5a25726d9c7e7bb58d2
5
5
  SHA512:
6
- metadata.gz: 2efac079381c10ed7584f921dbeab2c3b4ae96f63a4b32c470a986a2a521f0a91c94e26871e92bcdcd9718d05d2bb6c41bc051155868b9c3684695b5b662d27e
7
- data.tar.gz: 1506fe3f4715fd1f85b72d8de416d4dfdff144e02bce2837c741ab82d7928b2fed38e6c08fac19e1a5a0e155a9ec4c947407d1878107e5c15cc7947618f731dd
6
+ metadata.gz: d95cc6198e93afadd8a16b09125166a1b0a8474c21f44cb194ae1e77e0a1cfa2a1e35c2a752bf275b16c7d0ec54144d99c3a2940865db373f80d0b3668c77a39
7
+ data.tar.gz: 00cdcea3f7f60fed460de3992568f7bce85c680212cac766a55a2186782fa9e76d578326f4af29bee6dc877fd614fb875ed5430e9288be18209be0ac17638155
data/CHANGELOG.md CHANGED
@@ -1,4 +1,11 @@
1
- ## 5.6.0 (2026/8/28)
1
+ ## 5.6.1 (2026/09/09)
2
+
3
+ * [#160](https://github.com/radar/distance_of_time_in_words/issues/160): Fix `offset_delta` incorrectly folding the full UTC offset difference into the distance when comparing timestamps with different, unrelated offsets (e.g. one in UTC, one with an explicit `-08:00` offset) instead of only when they're the same clock across a real DST/tzdata transition - [@dblock](https://github.com/dblock).
4
+ * [#162](https://github.com/radar/distance_of_time_in_words/issues/162): Fix `same_clock?` incorrectly treating two `Time` values converted from `ActiveSupport::TimeWithZone` in different zones as the same clock on Rails >= 8.0, where `#to_time` returns a `Time` whose `#zone` is the `ActiveSupport::TimeZone` object itself rather than a `nil`/String abbreviation - [@dblock](https://github.com/dblock).
5
+ * [#165](https://github.com/radar/distance_of_time_in_words/issues/165): Fix `offset_delta` folding the full UTC offset change into the top-level distance, which could overcorrect past zero and report "less than 1 second" for a case where a small real elapsed time (e.g. 1 minute) crosses a much larger DST transition (e.g. Europe/Dublin's 1 hour fall-back) - the offset correction is now only applied to the sub-day leftover once the distance has already been split into calendar fields - [@dblock](https://github.com/dblock).
6
+ * [#170](https://github.com/radar/distance_of_time_in_words/issues/170): Fix `distance_of_time_in_words` unnecessarily converting an `ActiveSupport::TimeWithZone` argument to a plain `Time` via `#to_time`, which discarded its real zone on Rails < 8.0 and prevented `same_clock?` from correctly folding in a historical UTC offset change (e.g. Pacific/Norfolk's 2015 offset change, #153) on those versions - [@dblock](https://github.com/dblock).
7
+
8
+ ## 5.6.0 (2026/08/28)
2
9
 
3
10
  * [#145](https://github.com/radar/distance_of_time_in_words/pull/145): Add `dotiw/core` require path to use dotiw without overriding the Rails date helpers, `DOTIW.distance_of_time_in_words` shortcut - [@ermolaev](https://github.com/ermolaev).
4
11
  * [#146](https://github.com/radar/distance_of_time_in_words/pull/146): Add support for Rails 8 - [@dblock](https://github.com/dblock).
data/lib/dotiw/methods.rb CHANGED
@@ -5,8 +5,8 @@ module DOTIW
5
5
  extend self
6
6
 
7
7
  def distance_of_time_in_words_hash(from_time, to_time, options = {})
8
- from_time = from_time.to_time if !from_time.is_a?(Time) && from_time.respond_to?(:to_time)
9
- to_time = to_time.to_time if !to_time.is_a?(Time) && to_time.respond_to?(:to_time)
8
+ from_time = coerce_to_time(from_time)
9
+ to_time = coerce_to_time(to_time)
10
10
 
11
11
  DOTIW::TimeHash.new(nil, from_time, to_time, options).to_hash
12
12
  end
@@ -73,12 +73,28 @@ module DOTIW
73
73
  if value.is_a?(Numeric)
74
74
  Time.at(value)
75
75
  elsif value.respond_to?(:to_time)
76
- value.to_time
76
+ coerce_to_time(value)
77
77
  else
78
78
  raise ArgumentError, "#{value.inspect} can't be converted to a Time value"
79
79
  end
80
80
  end
81
81
 
82
+ # An ActiveSupport::TimeWithZone already carries its zone explicitly and
83
+ # supports every operation TimeHash needs (arithmetic, #advance, calendar
84
+ # accessors, #utc_offset), so there's no reason to convert it to a plain
85
+ # Time first. Doing so is actively harmful: on Rails < 8.0 (before
86
+ # to_time_preserves_timezone defaulted to :zone), TimeWithZone#to_time
87
+ # discards the real zone and returns a Time in the process's local zone
88
+ # instead, which loses the information TimeHash's same_clock? needs to
89
+ # correctly fold a historical UTC offset change into the distance
90
+ # (#153/#162/#165) on Rails < 8.0 (#170) - the loss isn't inherent to
91
+ # older Rails, it's an unforced conversion we don't need to make.
92
+ def coerce_to_time(value)
93
+ return value if value.is_a?(Time) || value.respond_to?(:time_zone)
94
+
95
+ value.to_time
96
+ end
97
+
82
98
  def options_with_scope(options)
83
99
  if options.key?(:compact)
84
100
  options.merge(scope: DOTIW::DEFAULT_I18N_SCOPE_COMPACT)
@@ -11,11 +11,9 @@ module DOTIW
11
11
  @options = options.dup
12
12
  @distance = distance
13
13
  @from_time = from_time || Time.current
14
- @to_time = to_time || (@to_time_not_given = true && @from_time + distance.seconds)
14
+ @to_time = to_time || (@from_time + distance.seconds)
15
15
  @smallest, @largest = [@from_time, @to_time].minmax
16
- @to_time += offset_delta(smallest, largest) if @to_time_not_given
17
- @smallest, @largest = [@from_time, @to_time].minmax
18
- @distance ||= (largest - smallest) + offset_delta(smallest, largest)
16
+ @distance ||= largest - smallest
19
17
 
20
18
  build_time_hash
21
19
  end
@@ -38,14 +36,56 @@ module DOTIW
38
36
  # reflects any change in UTC offset between them (whether from a DST
39
37
  # transition or a permanent tzdata rule change, e.g. Pacific/Norfolk's
40
38
  # 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.
39
+ # elapsed duration - this is why we never touch the top-level @distance
40
+ # used to pick which build_* branch to use. However, once we've split
41
+ # that distance into calendar fields (years/months/weeks/days, #153),
42
+ # we want the offset difference folded out of the *sub-day leftover*
43
+ # only, so it reflects actual elapsed wall-clock time rather than an
44
+ # artifact of an offset shift, without risking flipping the sign of the
45
+ # much larger top-level distance the way applying this correction
46
+ # globally used to (#165).
47
+ #
48
+ # This only makes sense when both times are the same clock (the same
49
+ # location/zone before and after a transition). If they're simply
50
+ # expressed with different, unrelated UTC offsets (e.g. one in UTC and
51
+ # one with an explicit "-08:00" offset), any difference between their
52
+ # offsets is an artifact of how each was represented, not a transition,
53
+ # and folding it in would double count what subtraction already got
54
+ # right (#160).
45
55
  def offset_delta(smallest, largest)
56
+ return 0 unless same_clock?(smallest, largest)
57
+
46
58
  largest.utc_offset - smallest.utc_offset
47
59
  end
48
60
 
61
+ # Whether smallest and largest are readings of the same underlying
62
+ # clock, as opposed to two independently fixed offsets that simply
63
+ # happen to differ (e.g. one in UTC, one with an explicit "-08:00").
64
+ # Only in the former case does a UTC offset difference represent a
65
+ # real transition (DST, or a historical tzdata rule change) rather
66
+ # than an artifact of how each time happens to be represented.
67
+ #
68
+ # An ActiveSupport::TimeWithZone carries its zone explicitly, so two
69
+ # of them share a clock when their +time_zone+ matches. A plain Ruby
70
+ # Time normally reports its zone as a String abbreviation (e.g. "PST")
71
+ # or +nil+ (fixed numeric offset, which can never transition, so two
72
+ # such times can't share a clock). Depending on the Ruby/ActiveSupport
73
+ # version, however, converting an ActiveSupport::TimeWithZone via
74
+ # +#to_time+ can instead produce a plain Time whose +#zone+ is the
75
+ # ActiveSupport::TimeZone object itself (#162) - in that case we
76
+ # compare the zone objects directly rather than assuming any two
77
+ # non-String zones both refer to the process's system zone.
78
+ def same_clock?(smallest, largest)
79
+ if smallest.respond_to?(:time_zone) || largest.respond_to?(:time_zone)
80
+ smallest.respond_to?(:time_zone) && largest.respond_to?(:time_zone) &&
81
+ smallest.time_zone == largest.time_zone
82
+ elsif !smallest.zone.is_a?(String) || !largest.zone.is_a?(String)
83
+ !smallest.zone.nil? && smallest.zone == largest.zone
84
+ else
85
+ !smallest.zone.nil? && !smallest.utc? && !largest.zone.nil? && !largest.utc?
86
+ end
87
+ end
88
+
49
89
  def build_time_hash
50
90
  if (max_unit = options[:max_unit])
51
91
  build_max_unit(max_unit.to_sym)
@@ -191,9 +231,28 @@ module DOTIW
191
231
  output[:weeks] = weeks
192
232
  output[:days] = days
193
233
 
194
- total_days, @distance = distance.abs.divmod(ONE_DAY.to_i)
195
-
196
- [total_days, @distance]
234
+ # total_days is discarded: years/months/weeks/days above are derived
235
+ # from calendar components (largest/smallest year/month/day), an
236
+ # entirely separate calculation from @distance. All we need from here
237
+ # on is the leftover below the day boundary, which is just @distance
238
+ # mod one day - decoupled like this (rather than reconstructed by
239
+ # advancing smallest by years/months/weeks/days and diffing against
240
+ # largest) so a calendar edge case where that reconstruction doesn't
241
+ # land exactly on largest (e.g. Jan 31 -> Mar 2) can never leave a
242
+ # leftover large enough to loop back into this same branch forever.
243
+ # We do fold in any UTC offset change between smallest and largest
244
+ # (#153) exactly once - guarded, since build_years and build_months
245
+ # both call this method unconditionally, and accumulate_on: :years
246
+ # ends up invoking it twice (once via build_years, once via
247
+ # build_months) - but only here, not in the top-level @distance used
248
+ # to pick which build_* branch to use in the first place, so a real
249
+ # elapsed time much smaller than the offset change (#165) still gets
250
+ # bucketed correctly.
251
+ unless @offset_applied
252
+ @offset_applied = true
253
+ @distance = distance.abs + offset_delta(smallest, largest)
254
+ end
255
+ _total_days, @distance = distance.abs.divmod(ONE_DAY.to_i)
197
256
  end
198
257
  end
199
258
  end
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.6.0'
4
+ VERSION = '5.6.1'
5
5
  end
@@ -254,6 +254,110 @@ describe 'A better distance_of_time_in_words' do
254
254
  end
255
255
  end
256
256
 
257
+ context 'with different UTC offsets that are not the same clock (#160)' do
258
+ # Real, non-mocked reproduction from #160: comparing two timestamps
259
+ # expressed with different, unrelated UTC offsets (not the same
260
+ # location before/after a real transition) incorrectly folded the
261
+ # full offset difference between them into the reported distance.
262
+ # Neither endpoint here crosses any DST or historical offset
263
+ # transition; only their representation differs.
264
+ it 'is 1 hour, not 9 hours' do
265
+ from = Time.new(2026, 1, 15, 11, 0, 0, '-08:00')
266
+ to = Time.utc(2026, 1, 15, 20)
267
+
268
+ expect(to - from).to eq(3600.0)
269
+ expect(distance_of_time_in_words(from, to)).to eq('1 hour')
270
+ end
271
+
272
+ it 'is still 1 hour when both timestamps are in UTC' do
273
+ from = Time.new(2026, 1, 15, 11, 0, 0, '-08:00').getutc
274
+ to = Time.utc(2026, 1, 15, 20)
275
+
276
+ expect(distance_of_time_in_words(from, to)).to eq('1 hour')
277
+ end
278
+ end
279
+
280
+ context 'with Time values using different zone objects (#162)' do
281
+ # #to_time on an ActiveSupport::TimeWithZone (with to_time_preserves_timezone
282
+ # set, the default since Rails 7.1) returns a plain Time whose #zone is the
283
+ # ActiveSupport::TimeZone object itself, not a String abbreviation. Building
284
+ # these directly via TimeZone#local/#to_time, rather than Ruby's Time.new(in:)
285
+ # keyword (only available on Ruby 3.2+), reproduces the same shape of object
286
+ # portably across the whole supported Ruby/Rails matrix.
287
+ it 'treats different TimeZone-object zones as different clocks, without folding in any offset' do
288
+ tokyo_time = ActiveSupport::TimeZone['Asia/Tokyo'].local(2026, 1, 16, 4, 0, 0).to_time
289
+ la_time = ActiveSupport::TimeZone['America/Los_Angeles'].local(2026, 1, 15, 12, 0, 0).to_time
290
+
291
+ expect(la_time - tokyo_time).to eq(3600.0)
292
+ expect(distance_of_time_in_words(tokyo_time, la_time)).to eq('1 hour')
293
+ end
294
+
295
+ # Same TimeZone-object shape as above, but far enough apart to be split
296
+ # into calendar fields (years/months/weeks/days, #153/#165), which is
297
+ # the only place same_clock? is invoked (see #165). Tokyo and LA are
298
+ # unrelated zones (not the same clock before/after a transition), so
299
+ # this exercises same_clock?'s "not a String zone" comparison finding
300
+ # the two TimeZone objects unequal, exactly like the #160 case does
301
+ # for String zones.
302
+ it 'treats TimeZone-object zones far enough apart to span calendar fields as different clocks' do
303
+ tokyo_time = ActiveSupport::TimeZone['Asia/Tokyo'].local(2025, 1, 16, 4, 0, 0).to_time
304
+ la_time = ActiveSupport::TimeZone['America/Los_Angeles'].local(2026, 3, 15, 12, 0, 0).to_time
305
+
306
+ expect(distance_of_time_in_words(tokyo_time, la_time, true)).to eq('1 year, 1 month, 3 weeks, and 6 days')
307
+ end
308
+ end
309
+
310
+ context 'with explicit named zones' do
311
+ # https://github.com/moment/luxon/blob/3.7.2/test/datetime/diff.test.js#L317-L330
312
+ it 'preserves the elapsed time between UTC and CEST' do
313
+ from = Time.utc(2022, 5, 5, 23, 0, 0)
314
+ to = Time.new(2022, 5, 10, 0, 0, 0, '+02:00')
315
+
316
+ expect(to - from).to eq(3.days + 23.hours)
317
+ expect(distance_of_time_in_words(from, to, true)).to eq('3 days and 23 hours')
318
+ end
319
+
320
+ # https://github.com/bitwalker/timex/blob/3.7.11/test/format_duration_humanized_test.exs
321
+ it 'preserves one minute across the Europe/Dublin DST fall-back (#165)' do
322
+ # Passing an ActiveSupport::TimeWithZone straight through (rather than
323
+ # calling #to_time on it, as in the #162 example above) exercises the
324
+ # same_clock? branch that compares #time_zone directly. Prior to the
325
+ # #165 fix, dotiw unconditionally called #to_time on any TimeWithZone
326
+ # argument, which discarded the real zone entirely on Rails < 8.0 (see
327
+ # DOTIW::Methods#coerce_to_time, #170) - so this was only reproducible
328
+ # on Rails >= 8.0, and passed for the wrong reason everywhere else. Now
329
+ # that #coerce_to_time no longer makes that unforced conversion, and
330
+ # offset_delta is only ever applied to the sub-day leftover rather
331
+ # than the top-level distance, this passes for the right reason on
332
+ # every supported Rails version.
333
+ dublin = ActiveSupport::TimeZone['Europe/Dublin']
334
+ from = Time.utc(2024, 10, 27, 0, 59, 30).in_time_zone(dublin)
335
+ to = from + 1.minute
336
+
337
+ expect(from.utc_offset).to eq(1.hour)
338
+ expect(to.utc_offset).to eq(0)
339
+ expect(to - from).to eq(1.minute)
340
+ expect(distance_of_time_in_words(from, to)).to eq('1 minute')
341
+ end
342
+
343
+ # https://github.com/dblock/tz_test/blob/master/ruby/test.rb
344
+ it 'folds the Pacific/Norfolk historical offset change into the calendar distance' do
345
+ # As with the Dublin example above, from/to are passed through as
346
+ # ActiveSupport::TimeWithZone rather than being converted with
347
+ # #to_time, so their real zone is preserved on every Rails version
348
+ # (see DOTIW::Methods#coerce_to_time, #170) and same_clock? can
349
+ # correctly recognize this as the same clock across Pacific/Norfolk's
350
+ # 2015 historical UTC offset change.
351
+ norfolk = ActiveSupport::TimeZone['Pacific/Norfolk']
352
+ from = norfolk.local(2015, 1, 15)
353
+ to = norfolk.local(2016, 3, 15)
354
+
355
+ expect(from.utc_offset).to eq(11.hours + 30.minutes)
356
+ expect(to.utc_offset).to eq(11.hours)
357
+ expect(distance_of_time_in_words(from, to, true)).to eq('1 year and 2 months')
358
+ end
359
+ end
360
+
257
361
  describe 'accumulate_on:' do
258
362
  [
259
363
  [START_TIME,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dotiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.0
4
+ version: 5.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Bigg
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-28 00:00:00.000000000 Z
12
+ date: 2026-09-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport