relativeTime method

  1. @useResult
String? relativeTime({
  1. DateTime? now,
  2. bool isDescriptive = true,
  3. bool isDescriptiveTimeSuffix = true,
  4. bool roundUp = false,
})

Returns an English relative-time phrase between this DateTime and now (defaults to DateTime.now).

  • isDescriptive — verbose phrasing ("a moment", "about an hour", "5 minutes") when true; terse tokens ("now", "~1h", "5 min") when false.
  • isDescriptiveTimeSuffix — append relativeTimeSuffixPast / relativeTimeSuffixFuture ("ago" / "from now") when true.
  • roundUp — round the numeric unit (89.6 min2 hr band-permitting) instead of flooring.

Year-level spans use date-based calendar arithmetic (subtract years, back off one if the anniversary has not yet passed) rather than days / 365.25 to avoid off-by-one errors near anniversary boundaries.

Returns null only on the degenerate path where every band produces an empty string; for ordinary inputs a non-empty phrase is always returned.

Example:

final now = DateTime(2024, 6, 15, 12);
DateTime(2024, 6, 15, 11, 55).relativeTime(now: now); // "5 minutes ago"
DateTime(2024, 6, 20).relativeTime(now: DateTime(2024, 6, 15));
//                                                    // "5 days from now"

Audited: 2026-06-12 11:26 EDT

Implementation

@useResult
String? relativeTime({
  DateTime? now,
  bool isDescriptive = true,
  bool isDescriptiveTimeSuffix = true,
  bool roundUp = false,
}) {
  // Exact-instant short-circuit must run before defaulting now, so that the
  // documented `this == now` contract holds even when now is passed null.
  if (this == now) {
    return relativeNowTime;
  }

  // Resolve once into a local (not a param reassignment) so a clock tick
  // mid-computation cannot shift the bucket and the original arg is preserved.
  final DateTime resolvedNow = now ?? DateTime.now();

  // Millisecond granularity; microsecond ties are intentionally ignored.
  final int signedElapsed = millisecondsSinceEpoch - resolvedNow.millisecondsSinceEpoch;

  // isBefore (not `signedElapsed < 0`) keeps direction correct at microsecond
  // ties where the millisecond delta can be zero yet the instants still differ.
  final bool isPast = isBefore(resolvedNow);
  final int elapsed = isPast ? signedElapsed.abs() : signedElapsed;

  final String? body = _relativePhraseBody(
    from: this,
    now: resolvedNow,
    elapsed: elapsed,
    isPast: isPast,
    isDescriptive: isDescriptive,
    roundUp: roundUp,
  );

  // Guard before interpolating: only the degenerate empty-band path is null.
  if (body == null || body.isEmpty) {
    return null;
  }

  if (isDescriptiveTimeSuffix) {
    return '$body ${isPast ? relativeTimeSuffixPast : relativeTimeSuffixFuture}';
  }

  return body;
}