distance_of_time_in_words is a small Ruby gem that turns two Time objects right into a human-readable string like “3 days and 4 hours”. Two separate bug reviews in opposition to it this yr turned out to be variations on the identical theme: computing a period between two timestamps will not be the trivial subtraction it seems like, the second time zones are concerned. Each fixes shipped in dotiw 5.6.0.
Bug 1: dst? Lies When You Least Count on It
#63 reported {that a} period of 1 minute was rendered as “lower than 1 second” for customers in Europe/Dublin. The gem’s TimeHash had a DST correction that regarded affordable:
d = largest - smallest
d -= 1.hour if smallest.dst? && !largest.dst?
d += 1.hour if !smallest.dst? && largest.dst?
The thought: if a DST transition occurred between the 2 instances, Time subtraction already accounts for the wall-clock leap, so cancel it again out earlier than splitting the period into calendar models. That works all over the place besides Eire. Europe/Dublin makes use of an inverted DST scheme: its winter time is legally outlined as “customary time minus one hour” moderately than the extra frequent “customary time is winter, summer season is +1”. Relying on whether or not a Time was constructed through Time.at(seconds) or datetime.to_time, dst? might report totally different values for the very same instantaneous, regardless that utc_offset agreed. The correction fired when it shouldn’t have, and an actual one-minute hole acquired silently zeroed out.
Reproducing it doesn’t even require mocking dst? — simply operating the instance with the precise TZ set is sufficient:
ENV['TZ'] = 'Europe/Dublin'
begin = Time.at(DateTime.now)
end = DateTime.now + 1.minute
# => "lower than 1 second"
# anticipated: "1 minute"
distance_of_time_in_words(begin, end)
The repair (PR #152) was to cease asking “is that this DST?” and simply evaluate the precise offsets:
def offset_decreased?(smallest, largest)
smallest.utc_offset > largest.utc_offset
finish
def offset_increased?(smallest, largest)
smallest.utc_offset < largest.utc_offset
finish
utc_offset doesn’t care how the Time was constructed or what a selected nation calls its winter clock — it’s simply the variety of seconds off UTC. Dependable, and it doesn’t require understanding something about Europe/Dublin’s particular authorized quirk.
Bug 2: DST Isn’t the Solely Factor That Adjustments an Offset
Fixing #63 with utc_offset comparisons was extra appropriate, however nonetheless baked in an assumption: that any offset change is a ±1 hour DST transition. #153 broke that assumption. Working the take a look at suite beneath TZ=Pacific/Norfolk produced a spurious “23 hours and half-hour” leaking into outcomes that ought to have simply been a clear calendar distance.
It seems Norfolk Island completely modified its UTC offset from +11:30 to +11:00 on 4 October 2015, introduced by the island’s Administrator a month earlier — a one-time tzdata rule change, not a recurring seasonal transition. The previous code’s ±1 hour hardcoding had no technique to signify a 30-minute, everlasting shift.
Once more, an actual (non-mocked) copy is sufficient — no dst? mismatch concerned this time, only a real historic offset change baked into tzdata itself:
ENV['TZ'] = 'Pacific/Norfolk'
begin = '2015-1-15'.to_time
end = '2016-3-15'.to_time
# => "1 yr, 2 months, 23 hours, and half-hour"
# anticipated: "1 yr and a couple of months"
distance_of_time_in_words(begin, end, true)
The true repair (PR #154) was to cease special-casing “1 hour” and generalize to regardless of the precise offset delta is:
def offset_delta(smallest, largest)
largest.utc_offset - smallest.utc_offset
finish
Right here’s why that’s wanted, labored out with actual numbers. begin.utc_offset is +11:30 (41400 seconds) and end.utc_offset is +11:00 (39600 seconds). Ruby’s Time subtraction (end - begin) already elements that in: it returns 36,721,800 seconds, which is 425 days and 1800 seconds (half-hour) — not a clear 425 days. That’s appropriate: 30 actual minutes did elapse because of the offset change, so the uncooked distance is correct.
The bug was within the subsequent step, the place that distance will get break up into years/months/weeks/days. That calendar breakdown doesn’t work from seconds in any respect — it reads largest.yr, largest.month, largest.day and subtracts smallest’s, that are plain calendar fields with no idea of UTC offset. Jan 15 to Mar 15 is a clear “1 yr, 2 months, 0 days” by the calendar, no the rest. In the meantime the previous buggy code utilized its ±1 hour DST correction earlier than computing the space (because it nonetheless thought by way of “1 hour”, not the true -1800 second delta), which left the mistaken the rest behind: 36,721,800 - 3600 = 36,718,200 seconds, i.e. 424 complete days plus an 84,600-second (23.5 hour) the rest — reported as “23 hours and half-hour”. Neither the ±1 hour hardcoding nor the 30-minute actuality had anyplace to go as soon as years/months/days had already consumed the calendar-shaped a part of the period, so it leaked out as bogus hours and minutes. Utilizing the true offset_delta (-1800, not ±3600) and making use of it constantly all over the place the code touches @distance removes that artifact completely, leaving simply “1 yr and a couple of months”.
It is a strictly extra common model of Bug 1’s repair — the “DST transition” case simply falls out as offset_delta taking place to equal ±3600 seconds. As soon as we stopped assuming what variety of offset change was doable, each the recurring and the one-off instances labored with the identical code path. The lesson: don’t encode a selected real-world trigger (DST, 1 hour) into your math when what you truly care about is a extra common impact (offset modified, by nevertheless a lot).
Do Different Languages Have This Downside?
Curious whether or not it is a dotiw-specific mistake or a entice each “humanize a time distinction” library falls into, I reproduced each eventualities — the Norfolk Island offset change and the Dublin DST-adjacent case — in opposition to related libraries in JavaScript, Python, Go, Rust, PHP, C#, Java, Elixir, Swift, Goal-C, and Dart: date-fns, dayjs, second.js, humanize, arrow, go-humanize, chrono-humanize, native DateTime::diff, Carbon, Humanizer, PrettyTime, Timex, humanizer, RelativeDateTimeFormatter/DateComponentsFormatter, and timeago. All of the take a look at code is on GitHub if you wish to run it your self.
Each certainly one of them was clear on the Dublin case, and each one however one was clear on Norfolk too. Right here’s the Norfolk Island case in JavaScript (date-fns) and Python (humanize):
course of.env.TZ = 'Pacific/Norfolk';
const begin = new Date(2015, 0, 15);
const end = new Date(2016, 2, 15);
// => "about 1 yr"
formatDistance(begin, end, { includeSeconds: true });
os.environ['TZ'] = 'Pacific/Norfolk'
begin = datetime(2015, 1, 15, tzinfo=ZoneInfo('Pacific/Norfolk'))
end = datetime(2016, 3, 15, tzinfo=ZoneInfo('Pacific/Norfolk'))
# => "1 yr, 2 months"
humanize.naturaldelta(end - begin)
And the Dublin case in Rust (chrono-humanize) and PHP (Carbon, the closest analog to dotiw because it additionally helps a compound breakdown):
let dstart = Dublin.with_ymd_and_hms(2024, 10, 27, 1, 59, 30).earliest().unwrap();
let dfinish = dstart + Period::minutes(1);
// => "in a minute"
HumanTime::from(dfinish.signed_duration_since(dstart))
$dstart = Carbon::create(2024, 10, 27, 1, 59, 30, 'Europe/Dublin');
$dfinish = $dstart->copy()->addMinute();
// => "1 minute earlier than"
$dstart->diffForHumans($dfinish);
// => "1 yr 2 months" (Norfolk case, compound breakdown, nonetheless clear)
Carbon::create(2015, 1, 15, 0, 0, 0, 'Pacific/Norfolk')
->diff(Carbon::create(2016, 3, 15, 0, 0, 0, 'Pacific/Norfolk'))
->forHumans();
C#’s Humanizer doesn’t try a calendar yr/month breakdown in any respect, solely weeks/days/hours/minutes, so the Norfolk case has no calendar-shaped bucket to leak into:
var norfolk = TimeZoneInfo.FindSystemTimeZoneById("Pacific/Norfolk");
var begin = new DateTimeOffset(2015, 1, 15, 0, 0, 0, norfolk.GetUtcOffset(new DateTime(2015, 1, 15)));
var end = new DateTimeOffset(2016, 3, 15, 0, 0, 0, norfolk.GetUtcOffset(new DateTime(2016, 3, 15)));
// => "60 weeks, 5 days, half-hour"
(end - begin).Humanize(precision: 5);
Java’s PrettyTime solely ever codecs a single instantaneous relative to a different (“1 yr from now”), so there’s no compound breakdown in any respect to leak into:
ZonedDateTime begin = ZonedDateTime.of(2015, 1, 15, 0, 0, 0, 0, ZoneId.of("Pacific/Norfolk"));
ZonedDateTime end = ZonedDateTime.of(2016, 3, 15, 0, 0, 0, 0, ZoneId.of("Pacific/Norfolk"));
// => "1 yr from now"
new PrettyTime(Date.from(begin.toInstant())).format(Date.from(end.toInstant()));
Elixir’s Timex, nevertheless, does reproduce the bug — identical form as dotiw’s authentic Norfolk failure, only a smaller leftover as a result of it computes the true offset delta as an alternative of hardcoding an hour:
{:okay, begin} = DateTime.new(~D[2015-01-15], ~T[00:00:00], "Pacific/Norfolk", Tzdata.TimeZoneDatabase)
{:okay, end} = DateTime.new(~D[2016-03-15], ~T[00:00:00], "Pacific/Norfolk", Tzdata.TimeZoneDatabase)
# => "1 yr, 2 months, half-hour"
Timex.Format.Period.Formatters.Humanized.format(
Timex.Period.from_seconds(DateTime.diff(end, begin))
)
That trailing “half-hour” is precisely the Norfolk offset delta leaking out, the identical artifact dotiw used to supply as “23 hours and half-hour” earlier than PR #154. It’s a superb affirmation that the bug isn’t a Ruby-specific mistake a lot as a pure consequence of constructing a compound years/months/…/minutes breakdown from a uncooked second depend with out accounting for the offset change alongside the best way — most libraries simply occur to keep away from the compound breakdown (or, in Carbon’s case, keep away from the bug regardless of it) moderately than being resistant to the underlying entice.
I opened bitwalker/timex PR #793 with a repair, following the identical technique as dotiw’s: as an alternative of formatting an opaque Period (which has already misplaced all calendar context by the point it reaches the formatter), the repair provides a format/2 that takes each datetimes straight, computes years/months through actual calendar arithmetic, and solely converts the true leftover to a period:
Timex.Format.Period.Formatters.Humanized.format(begin, end)
# => "1 yr, 2 months"
Whereas testing this repair, I additionally discovered that format/2 crashes if end comes earlier than begin — detrimental years/months get handed straight into Gettext’s plural translation, which requires a non-negative depend. format/1 has at all times been sign-independent (Period.from_erl({0, -65, 0}) and Period.from_erl({0, 65, 0}) each format the identical means), so format/2 needs to be too. Filed as a follow-up, bitwalker/timex PR #794.
Timex itself is essentially unmaintained at this level — the final push to most important was mid-2025, and it has greater than 70 open points — so whereas I used to be at it, I checked whether or not a maintained various avoids this complete class of bug. humanizer is a small, actively developed, English-only library with a relative_time/2,3 operate. It’s clear on each the Norfolk and Dublin instances, and it additionally handles reversed argument order accurately with out crashing — it diffs absolute instants and branches on signal moderately than doing calendar-aware yr/month shifting, which sidesteps the bug class structurally at the price of utilizing fixed-width buckets (7/30/12 months) for weeks/months/years as an alternative of actual calendar arithmetic. The copy is in the identical take a look at repo, beneath elixir/humanizer_test/.
I later prolonged the copy to Swift, Goal-C, and Dart. Swift and Goal-C share the identical underlying Basis implementation: RelativeDateTimeFormatter/NSRelativeDateTimeFormatter (single-largest-unit “time in the past” model) and DateComponentsFormatter/NSDateComponentsFormatter (a compound breakdown, straight analogous to dotiw’s output). All clear — Norfolk, Dublin, reversed order, and nil distance. Dart’s timeago bundle is clear too.
One factor initially regarded like a fourth bug throughout that move, value mentioning as a result of I acquired it mistaken at first. Given a reversed (fromDate, toDate) pair, NSDateComponentsFormatter renders:
NSDateComponentsFormatter *f = [[NSDateComponentsFormatter alloc] init];
f.unitsStyle = NSDateComponentsFormatterUnitsStyleFull;
f.allowedUnits = NSCalendarUnitYear | NSCalendarUnitMonth;
f.calendar = cal; // Pacific/Norfolk
[f stringFromDate:start toDate:finish] // => "1 yr, 2 months"
[f stringFromDate:finish toDate:start] // => "-1 yr, 2 months"
That appears inconsistent — certainly it ought to learn "-1 yr, -2 months" if the underlying delta is detrimental in each fields? However that is truly customary mixed-radix detrimental notation, the identical conference used for detrimental durations (-1:30:00 means minus one-and-a-half hours, not “minus one hour plus thirty minutes”) or detrimental levels/minutes/seconds coordinates: solely the main unit carries the signal, and the remainder are magnitudes of that very same detrimental amount. NSCalendar confirms that is intentional: the uncooked parts actually are yr=-1, month=-2 beneath, and the formatter accurately collapses that right into a single main signal for show, precisely because it ought to. Not a bug — see the take a look at repo beneath objc/ and swift/ for the complete copy and reasoning.
The primary motive not one of the others reproduce the bug is structural: most spherical to a single largest unit (“about 1 yr”, “a minute in the past”) as an alternative of constructing a compound breakdown throughout years, months, weeks, days, hours, and minutes the best way dotiw does. With nowhere calendar-shaped for a stray half-hour or 23 hours to finish up, there’s no the rest left to misattribute. Carbon is the exception that proves the rule: it does assist a compound breakdown much like dotiw’s output, and nonetheless will get it proper, as a result of the offset math occurs accurately beneath, on the DateInterval stage, earlier than any splitting into models happens.
One different factor stood out whereas testing the Dublin case in Rust and C#. Each refuse to allow you to assemble a neighborhood time that falls in an ambiguous window (the “fall again” hour that happens twice) with out dealing with it explicitly. Rust’s chrono-tz:
match Dublin.with_ymd_and_hms(2024, 10, 27, 1, 59, 30) {
chrono::LocalResult::Single(dt) => println!("Single: {}", dt),
chrono::LocalResult::Ambiguous(a, b) => println!("Ambiguous: {} OR {}", a, b),
chrono::LocalResult::None => println!("None (would not exist, e.g. spring-forward hole)"),
}
# => Ambiguous: 2024-10-27 01:59:30 IST OR 2024-10-27 01:59:30 GMT
And .NET’s TimeZoneInfo, which surfaces the identical reality through an express question as an alternative of an enum:
var dublin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Dublin");
var native = new DateTime(2024, 10, 27, 1, 59, 30, DateTimeKind.Unspecified);
dublin.IsAmbiguousTime(native); // => true
dublin.GetAmbiguousTimeOffsets(native); // => [00:00:00, 01:00:00]
Ruby (and a lot of the different languages examined) will silently decide one interpretation of an ambiguous wall-clock time and transfer on. Forcing the caller to disambiguate explicitly is precisely the sort of design that may have made a bug like #63 more durable to put in writing within the first place.
The Widespread Thread
Each bugs share a form: a plausible-looking shortcut (dst? as an alternative of utc_offset, “appropriate by precisely 1 hour”) that works for the overwhelmingly frequent case and quietly breaks for a selected, real-world edge case {that a} bug reporter with an uncommon time zone ultimately bumped into. Neither was caught by the present take a look at suite, as a result of the take a look at suite ran in a single time zone, on inputs that by no means crossed the affected boundaries.
The precise repair was the identical in spirit every time: substitute the precise assumption with the final, verifiable reality it was standing in for — precise offsets as an alternative of a DST flag, an arbitrary delta as an alternative of a hard and fast hour. For those who keep a library that touches wall-clock time, it’s value asking, for each “apparent” shortcut within the code, what real-world weirdness it’s quietly assuming doesn’t exist. Europe/Dublin and Pacific/Norfolk are extra frequent exceptions to your assumptions than you’d suppose.

