jacobras / Human-Readable

A small set of data formatting utilities for Kotlin Multiplatform (KMP).
MIT License
156 stars 10 forks source link

Support approximation words, multiple units, customizable precision #43

Open rocketraman opened 5 months ago

rocketraman commented 5 months ago

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.

rocketraman commented 5 months ago

@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.

jacobras commented 5 months ago

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.