Support approximation words, multiple units, customizable precision
Great library. I have a mini-lib that does the same thing, and I would love to migrate to Human-Readable, but my mini-lib seems to have a couple features I need that it appears Human-Readable does not. It supports (optional) approximation words and customizable precision. So for example, a duration from
2024-03-19T19:55:18.994Z to 2024-03-19T19:58:26994Z
could output:
"about 3 minutes"
and a negative duration of the same length would output:
"about 3 minutes ago"
It also supports multiple units in some cases where that makes sense. For example, a duration from 2024-03-20T20:14:13.536Z to 2024-03-21T22:14:14.536Z would output:
"about 1 day, 2 hours"
Here are some of my unit tests in my mini-lib for reference:
class TimeServiceTest : FunSpec({
test("Outputs a human readable duration") {
val tests = listOf(
15.seconds to "less than 30 seconds",
45.seconds to "45 seconds",
(1.minutes + 45.seconds) to "about 1 minute",
(2.minutes + 15.seconds) to "about 2 minutes",
(1.hours + 4.minutes) to "about 1 hour",
(1.hours + 5.minutes) to "about 1 hour, 5 minutes",
(1.hours + 55.minutes) to "about 1 hour, 55 minutes",
(2.hours + 4.minutes) to "about 2 hours",
(2.hours + 5.minutes) to "about 2 hours, 5 minutes",
(2.hours + 55.minutes) to "about 2 hours, 55 minutes",
(24.hours + 4.minutes) to "about 24 hours",
(24.hours + 5.minutes) to "about 24 hours",
(24.hours + 55.minutes) to "about 24 hours",
(26.hours + 4.minutes) to "about 26 hours",
(26.hours + 5.minutes) to "about 26 hours",
(26.hours + 55.minutes) to "about 26 hours",
2.days to "2 days",
(2.days + 4.minutes) to "about 2 days",
(2.days + 5.minutes) to "about 2 days",
(2.days + 1.hours + 4.minutes) to "about 2 days, 1 hour",
(2.days + 2.hours + 4.minutes) to "about 2 days, 2 hours",
(3.days) to "3 days",
(3.days + 4.minutes) to "about 3 days",
(3.days + 5.minutes) to "about 3 days",
(3.days + 1.hours + 4.minutes) to "about 3 days, 1 hour",
(3.days + 2.hours + 4.minutes) to "about 3 days, 2 hours",
(4.days) to "4 days",
(4.days + 4.minutes) to "about 4 days",
(4.days + 5.minutes) to "about 4 days",
(4.days + 1.hours + 4.minutes) to "about 4 days, 1 hour",
(4.days + 2.hours + 4.minutes) to "about 4 days, 2 hours",
)
tests.forEach { (input, output) ->
input.asClue {
it.humanReadable() shouldBe output
}
}
tests.map { (input, output) ->
-input to "$output ago"
}.forEach { (reversedInput, reversedOutput) ->
reversedInput.asClue {
it.humanReadable() shouldBe reversedOutput
}
}
}
})
and my implementation looks like this:
object TimeService {
private const val DEFAULT_MULTI_DAY_HOUR_CUTOFF = 23
private const val DEFAULT_HOUR_MINUTES_CUTOFF = 30
private const val DEFAULT_MULTI_HOUR_MINUTES_CUTOFF = 5
private const val DEFAULT_SECONDS_CUTOFF = 30
/**
* For the receiver, returns a human-readable string that describes the difference between the receiver and
* the given instant i.e. the receiver is relative to the given instant.
*
* If the receiver is after the given instant, the string will be of the form "expected <human-readable duration> ago".
* If the receiver is before the given instant, the string will be of the form "expected in <human-readable duration>".
*/
fun Instant.humanReadableRelativeTo(instant: Instant): String =
(instant - this).let { "${if (it.isNegative()) "expected" else "expected in"} ${it.humanReadable()}" }
/**
* For the receiver, returns a human-readable string that describes the difference between the receiver and
* the given instant i.e. the receiver is relative to the given instant.
*
* If the receiver is after the given instant, the string will be of the form "expected <human-readable duration> ago".
* If the receiver is before the given instant, the string will be of the form "expected in <human-readable duration>".
*/
fun LocalDate.humanReadableRelativeTo(date: LocalDate, zone: TimeZone): String =
atStartOfDayIn(zone).humanReadableRelativeTo(date.atStartOfDayIn(zone))
fun LocalDateTime.humanReadableRelativeTo(date: LocalDateTime, zone: TimeZone): String =
date.toInstant(zone).humanReadableRelativeTo(toInstant(zone))
@Suppress("ComplexMethod")
fun Duration.humanReadable(
approximationWord: String = "about ",
positiveSuffix: String = "",
negativeSuffix: String = " ago",
long: Boolean = true,
multiDayHourCutoff: Int = DEFAULT_MULTI_DAY_HOUR_CUTOFF,
hourMinutesCutoff: Int = DEFAULT_HOUR_MINUTES_CUTOFF,
multiHourMinutesCutoff: Int = DEFAULT_MULTI_HOUR_MINUTES_CUTOFF,
secondsCutoff: Int = DEFAULT_SECONDS_CUTOFF,
): String {
val ds = if (long) " days" else "d"
val d = if (long) " day" else "d"
val hs = if (long) " hours" else "h"
val h = if (long) " hour" else "h"
val ms = if (long) " minutes" else "m"
val m = if (long) " minute" else "m"
val ss = if (long) " seconds" else "s"
val lt = if (long) "less than " else "< "
return toComponents { days, hours, minutes, seconds, _ ->
when {
isNegative() ->
"${absoluteValue.humanReadable(
approximationWord = approximationWord,
long = long,
multiDayHourCutoff = multiDayHourCutoff,
hourMinutesCutoff = hourMinutesCutoff,
multiHourMinutesCutoff = multiHourMinutesCutoff,
secondsCutoff = secondsCutoff
)}$negativeSuffix"
days >= 2 && hours > 1 ->
"${approximationWord}$days$ds, $hours$hs$positiveSuffix"
days >= 2 && hours == 1 ->
"${approximationWord}$days$ds, 1$h$positiveSuffix"
// 48 - 49 hours
days >= 2 && hours == 0 && minutes == 0 && seconds == 0 ->
"$days$ds$positiveSuffix"
days == 1L && hours == 0 && minutes == 0 && seconds == 0 ->
"$days$d$positiveSuffix"
days >= 2 ->
"${approximationWord}$days$ds$positiveSuffix"
// 48 - 49 hours
days >= 1 && hours >= multiDayHourCutoff && minutes >= hourMinutesCutoff ->
"${approximationWord}2$ds$positiveSuffix"
// 24 to 47.5 hours
days >= 1 ->
"${approximationWord}$inWholeHours$hs$positiveSuffix"
// now get progressively more accurate
hours > 1 && minutes >= multiHourMinutesCutoff ->
"${approximationWord}$hours$hs, $minutes$ms$positiveSuffix"
hours == 1 && minutes >= multiHourMinutesCutoff ->
"${approximationWord}1$h, $minutes$ms$positiveSuffix"
hours > 1 ->
"${approximationWord}$hours$hs$positiveSuffix"
hours == 1 ->
"${approximationWord}1$h$positiveSuffix"
minutes > 1 ->
"${approximationWord}$minutes$ms$positiveSuffix"
minutes == 1 ->
"${approximationWord}1$m$positiveSuffix"
seconds >= secondsCutoff ->
"$seconds$ss$positiveSuffix"
seconds >= 0 ->
"$lt$secondsCutoff$ss$positiveSuffix"
else -> this.toString()
}
}
}
}
Note that the precision is also customizable.
@jacobras What is your policy on backwards compatibility at this stage of the project? I could potentially spend some time on submitting one or more PRs for this issue, but would need to understand the parameters of the work.
I try to not introduce breaking changes :) Adding more precise output would be interesting, as long as it's fully localized.
Thanks for the suggestions! I'll take a look at it after #47 is fixed (as that's more important now). A configurable precision has been on my ideas list for a while. Approximity not yet, but it's interesting.
I'll get back to this.