Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a human readable representation of duration #14028

Merged
merged 3 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions lib/elixir/lib/calendar/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1030,9 +1030,12 @@ defmodule DateTime do
By default, `DateTime.to_iso8601/2` returns datetimes formatted in the "extended"
format, for human readability. It also supports the "basic" format through passing the `:basic` option.

Only supports converting datetimes which are in the ISO calendar,
attempting to convert datetimes from other calendars will raise.
You can also optionally specify an offset for the formatted string.
If none is given, the one in the given `datetime` is used.

Only supports converting datetimes which are in the ISO calendar.
If another calendar is given, it is automatically converted to ISO.
It raises if not possible.

WARNING: the ISO 8601 datetime format does not contain the time zone nor
its abbreviation, which means information is lost when converting to such
Expand Down Expand Up @@ -1346,6 +1349,11 @@ defmodule DateTime do
@doc """
Converts the given `datetime` to a string according to its calendar.

Unfortunately, there is no standard that specifies rendering of a
datetime with its complete time zone information, so Elixir uses a
custom (but relatively common) representation which appends the time
zone abbreviation and full name to the datetime.

### Examples

iex> dt = %DateTime{year: 2000, month: 2, day: 29, zone_abbr: "CET",
Expand Down
121 changes: 114 additions & 7 deletions lib/elixir/lib/calendar/duration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,114 @@ defmodule Duration do
end
end

@doc """
Converts the given `duration` to a human readable representation.

## Options

* `:units` - the units to be used alongside each duration component.
The default units follow the ISO 80000-3 standard:

[
year: "a",
month: "mo",
week: "wk",
day: "d",
hour: "h",
minute: "min",
second: "s"
]

* `:separator` - a string used to separate the distinct components. Defaults to `" "`.

## Examples

iex> Duration.to_string(Duration.new!(second: 30))
"30s"
iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12))
"40d 12h 42min 12s"

By default, this function uses ISO 80000-3 units, which uses "a" for years.
But you can customize all units via the units option:

iex> Duration.to_string(Duration.new!(year: 3))
"3a"
iex> Duration.to_string(Duration.new!(year: 3), units: [year: "y"])
"3y"

You may also choose the separator:

iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12), separator: ", ")
"40d, 12h, 42min, 12s"

A duration without components is rendered as "0s":

iex> Duration.to_string(Duration.new!([]))
"0s"

Microseconds are rendered as part of seconds with the appropriate precision:

iex> Duration.to_string(Duration.new!(second: 1, microsecond: {2_200, 3}))
"1.002s"
iex> Duration.to_string(Duration.new!(second: 1, microsecond: {-1_200_000, 4}))
"-0.2000s"
josevalim marked this conversation as resolved.
Show resolved Hide resolved

"""
@doc since: "1.18.0"
def to_string(%Duration{} = duration, opts \\ []) do
units = Keyword.get(opts, :units, [])
separator = Keyword.get(opts, :separator, " ")

case to_string_year(duration, [], units) do
[] ->
"0" <> Keyword.get(units, :second, "s")

[part] ->
IO.iodata_to_binary(part)

parts ->
parts |> Enum.reduce(&[&1, separator | &2]) |> IO.iodata_to_binary()
end
end

defp to_string_part(0, _units, _key, _default, acc),
do: acc

defp to_string_part(x, units, key, default, acc),
do: [[Integer.to_string(x) | Keyword.get(units, key, default)] | acc]

defp to_string_year(%{year: year} = duration, acc, units) do
to_string_month(duration, to_string_part(year, units, :year, "a", acc), units)
end

defp to_string_month(%{month: month} = duration, acc, units) do
to_string_week(duration, to_string_part(month, units, :month, "mo", acc), units)
end

defp to_string_week(%{week: week} = duration, acc, units) do
to_string_day(duration, to_string_part(week, units, :week, "wk", acc), units)
end

defp to_string_day(%{day: day} = duration, acc, units) do
to_string_hour(duration, to_string_part(day, units, :day, "d", acc), units)
end

defp to_string_hour(%{hour: hour} = duration, acc, units) do
to_string_minute(duration, to_string_part(hour, units, :hour, "h", acc), units)
end

defp to_string_minute(%{minute: minute} = duration, acc, units) do
to_string_second(duration, to_string_part(minute, units, :minute, "min", acc), units)
end

defp to_string_second(%{second: 0, microsecond: {0, _}}, acc, _units) do
acc
end

defp to_string_second(%{second: s, microsecond: {ms, p}}, acc, units) do
[[second_component(s, ms, p) | Keyword.get(units, :second, "s")] | acc]
end

@doc """
Converts the given `duration` to an [ISO 8601-2:2019](https://en.wikipedia.org/wiki/ISO_8601) formatted string.

Expand Down Expand Up @@ -407,15 +515,15 @@ defmodule Duration do
[]
end

defp second_component(%{second: 0, microsecond: {_, 0}}) do
~c"0S"
defp second_component(%{second: second, microsecond: {ms, p}}) do
[second_component(second, ms, p), ?S]
end

defp second_component(%{second: second, microsecond: {_, 0}}) do
[Integer.to_string(second), ?S]
defp second_component(second, _ms, 0) do
Integer.to_string(second)
end

defp second_component(%{second: second, microsecond: {ms, p}}) do
defp second_component(second, ms, p) do
total_ms = second * @microseconds_per_second + ms
second = total_ms |> div(@microseconds_per_second) |> abs()
ms = total_ms |> rem(@microseconds_per_second) |> abs()
Expand All @@ -425,8 +533,7 @@ defmodule Duration do
sign,
Integer.to_string(second),
?.,
ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p),
?S
ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p)
]
end

Expand Down
3 changes: 3 additions & 0 deletions lib/elixir/lib/calendar/naive_datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,9 @@ defmodule NaiveDateTime do
@doc """
Converts the given naive datetime to a string according to its calendar.

For redability, this function follows the RFC3339 suggestion of removing
the "T" separator between the date and time components.

### Examples

iex> NaiveDateTime.to_string(~N[2000-02-28 23:00:13])
Expand Down
71 changes: 71 additions & 0 deletions lib/elixir/test/elixir/calendar/duration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,75 @@ defmodule DurationTest do
assert %Duration{microsecond: {-800_000, 0}} |> Duration.to_iso8601() == "PT0S"
assert %Duration{microsecond: {-1_200_000, 2}} |> Duration.to_iso8601() == "PT-1.20S"
end

test "to_string/1" do
assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}) ==
"1a 2mo 3d 4h 5min 6s"

assert Duration.to_string(%Duration{week: 3, hour: 5, minute: 3}) ==
"3wk 5h 3min"

assert Duration.to_string(%Duration{hour: 5, minute: 3}) ==
"5h 3min"

assert Duration.to_string(%Duration{year: 1, month: 2, day: 3}) ==
"1a 2mo 3d"

assert Duration.to_string(%Duration{hour: 4, minute: 5, second: 6}) ==
"4h 5min 6s"

josevalim marked this conversation as resolved.
Show resolved Hide resolved
assert Duration.to_string(%Duration{year: 1, month: 2}) ==
"1a 2mo"

assert Duration.to_string(%Duration{day: 3}) ==
"3d"

assert Duration.to_string(%Duration{hour: 4, minute: 5}) ==
"4h 5min"

assert Duration.to_string(%Duration{second: 6}) ==
"6s"

assert Duration.to_string(%Duration{second: 1, microsecond: {600_000, 1}}) ==
"1.6s"

assert Duration.to_string(%Duration{second: -1, microsecond: {-600_000, 1}}) ==
"-1.6s"

assert Duration.to_string(%Duration{second: -1, microsecond: {-234_567, 6}}) ==
"-1.234567s"

assert Duration.to_string(%Duration{second: 1, microsecond: {123_456, 6}}) ==
"1.123456s"

assert Duration.to_string(%Duration{year: 3, week: 4, day: -3, second: -6}) ==
"3a 4wk -3d -6s"

assert Duration.to_string(%Duration{second: -4, microsecond: {-230_000, 2}}) ==
"-4.23s"

assert Duration.to_string(%Duration{second: -4, microsecond: {230_000, 2}}) ==
"-3.77s"

assert Duration.to_string(%Duration{second: 2, microsecond: {-1_200_000, 4}}) ==
"0.8000s"

assert Duration.to_string(%Duration{second: 1, microsecond: {-1_200_000, 3}}) ==
"-0.200s"

assert Duration.to_string(%Duration{microsecond: {-800_000, 2}}) ==
"-0.80s"

assert Duration.to_string(%Duration{microsecond: {-800_000, 0}}) ==
"0s"

assert Duration.to_string(%Duration{microsecond: {-1_200_000, 2}}) ==
"-1.20s"

assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6},
units: [year: "year", month: "month", day: "day"],
separator: "-"
) ==
"1year-2month-3day-4h-5min-6s"
end
end
Loading