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 +4 -4
- data/CHANGELOG.md +8 -1
- data/lib/dotiw/methods.rb +19 -3
- data/lib/dotiw/time_hash.rb +70 -11
- data/lib/dotiw/version.rb +1 -1
- data/spec/lib/dotiw_spec.rb +104 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa29e6fb27292383b6c52e90e638a79df342e512d2132fc43399fbdac2accce7
|
|
4
|
+
data.tar.gz: a2a5f6a9f954ffa985ecb48a5888f860191be892e451f5a25726d9c7e7bb58d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d95cc6198e93afadd8a16b09125166a1b0a8474c21f44cb194ae1e77e0a1cfa2a1e35c2a752bf275b16c7d0ec54144d99c3a2940865db373f80d0b3668c77a39
|
|
7
|
+
data.tar.gz: 00cdcea3f7f60fed460de3992568f7bce85c680212cac766a55a2186782fa9e76d578326f4af29bee6dc877fd614fb875ed5430e9288be18209be0ac17638155
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
## 5.6.
|
|
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 =
|
|
9
|
-
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
|
|
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)
|
data/lib/dotiw/time_hash.rb
CHANGED
|
@@ -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
|
|
14
|
+
@to_time = to_time || (@from_time + distance.seconds)
|
|
15
15
|
@smallest, @largest = [@from_time, @to_time].minmax
|
|
16
|
-
@
|
|
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
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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
data/spec/lib/dotiw_spec.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
12
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|