diff --git a/book/src/conversion-functions.md b/book/src/conversion-functions.md index 6603ad92..25544a7e 100644 --- a/book/src/conversion-functions.md +++ b/book/src/conversion-functions.md @@ -12,7 +12,7 @@ now() -> unixtime # Convert a date and time to a different timezone now() -> tz("Asia/Kathmandu") -# Convert a duration to days, hours, minutes, seconds +# Convert a duration to years, months, days, hours, minutes, seconds 10 million seconds -> human # Convert an angle to degrees, minutes, seconds (48° 46′ 32″) diff --git a/book/src/date-and-time.md b/book/src/date-and-time.md index 77e218ea..d157034f 100644 --- a/book/src/date-and-time.md +++ b/book/src/date-and-time.md @@ -30,7 +30,7 @@ now() -> unixtime # What is the date corresponding to a given UNIX timestamp? from_unixtime(1707568901) -# How long are one million seconds in days, hours, minutes, seconds? +# How long are one million seconds in years, months, days, hours, minutes, seconds? 1 million seconds -> human ``` diff --git a/book/src/list-functions-datetime.md b/book/src/list-functions-datetime.md index 8319a503..8eaa2fdc 100644 --- a/book/src/list-functions-datetime.md +++ b/book/src/list-functions-datetime.md @@ -242,7 +242,7 @@ fn human(time: Time) -> String How long is a microcentury?
>>> century/1e6 -> human - = "52 minutes + 35.692505184 seconds" [String] + = "52 minutes + 35.693 seconds" [String]
diff --git a/examples/tests/human.nbt b/examples/tests/human.nbt index c8e9408d..e3e91536 100644 --- a/examples/tests/human.nbt +++ b/examples/tests/human.nbt @@ -22,17 +22,26 @@ assert_eq((1.37 day -> human), "1 day + 8 hours + 52 minutes + 48 second assert_eq((1 week -> human), "7 days") assert_eq((1.5 weeks -> human), "10 days + 12 hours") assert_eq((2 weeks -> human), "14 days") +assert_eq((2.5 weeks -> human), "17 days + 12 hours") -assert_eq((1 sidereal_day -> human), "23 hours + 56 minutes + 4.0905 seconds") +assert_eq((2 month -> human), "60.8737 days (approx. 2 months)") +assert_eq((2 month + 12 day -> human), "72.8737 days (approx. 2.4 months)") +assert_eq((3 yr + 2 month -> human), "1156.6 days (approx. 3 years + 2 months)") +assert_eq((10 yr + 2 s -> human), "3652.42 days (approx. 10 years)") -assert_eq((10000 days -> human), "10000 days") -assert_eq((50 million days -> human), "50_000_000 days") +assert_eq((1 sidereal_day -> human), "23 hours + 56 minutes + 4.091 seconds") -assert_eq((1e12 days -> human), "1_000_000_000_000 days") -assert_eq((1e15 days -> human), "1.0e+15 days") +assert_eq((10000 days -> human), "10000 days (approx. 27 years + 5 months)") +assert_eq((50 million days -> human), "50_000_000 days (approx. 136_895 years)") + +assert_eq((1e12 days -> human), "1_000_000_000_000 days (approx. 2_737_909_345 years)") +assert_eq((1e15 days -> human), "1.0e+15 days (approx. 2_737_909_345_034 years)") assert_eq((1 ms -> human), "0.001 seconds") assert_eq((1 µs -> human), "0.000001 seconds") -assert_eq((1 ns -> human), "0.000000001 seconds") +assert_eq((1 ns -> human), "1.0e-9 seconds") assert_eq((1234 ns -> human), "0.000001234 seconds") -assert_eq((1s + 1234 ns -> human), "1.000001234 seconds") +assert_eq((1s + 1234 ns -> human), "1 second") + +assert_eq((-1 second -> human), "1 second ago") +assert_eq((-7.89 hour -> human), "7 hours + 53 minutes + 24 seconds ago") diff --git a/numbat/modules/datetime/human.nbt b/numbat/modules/datetime/human.nbt index e6701e99..aab64aca 100644 --- a/numbat/modules/datetime/human.nbt +++ b/numbat/modules/datetime/human.nbt @@ -1,40 +1,59 @@ use core::functions use core::strings use units::si +use units::time use datetime::functions -fn _human_num_days(time: Time) -> Scalar = floor(time / days) - fn _human_join(a: String, b: String) -> String = - if str_slice(a, 0, 2) == "0 " then b else if str_slice(b, 0, 2) == "0 " then a else "{a} + {b}" - -fn _remove_plural_suffix(str: String) -> String = - if str_slice(str, 0, 2) == "1 " then str_slice(str, 0, str_length(str) - 1) else str - -fn _human_seconds(dt: DateTime) -> String = - _remove_plural_suffix(format_datetime("%-S%.f seconds", dt)) - -fn _human_minutes(dt: DateTime) -> String = - _remove_plural_suffix(format_datetime("%-M minutes", dt)) - -fn _human_hours(dt: DateTime) -> String = - _remove_plural_suffix(format_datetime("%-H hours", dt)) - -fn _human_days(num_days: Scalar) -> String = - _remove_plural_suffix("{num_days} days") - -fn _human_readable_duration(time: Time, dt: DateTime, num_days: Scalar) -> String = - _human_join(_human_join(_human_join(_human_days(_human_num_days(time)), _human_hours(dt)), _human_minutes(dt)), _human_seconds(dt)) + if a == "" then b else if b == "" then a else "{a} + {b}" + +fn _prettier(str: String) -> String = + if str_slice(clean_str, 0, 2) == "0 " then "" + else if str_slice(clean_str, 0, 2) == "1 " then str_slice(clean_str, 0, str_length(clean_str) - 1) + else clean_str + where clean_str = str_replace(str, ".0 ", " ") + +fn _human_years(time: Time) -> String = "{(time -> years) / year |> floor} years" -> _prettier +fn _human_months(time: Time) -> String = "{(time -> months) / month |> round} months" -> _prettier + +fn _human_days(time: Time) -> String = "{(time -> days) / day |> floor} days" -> _prettier +fn _human_hours(time: Time) -> String = "{(time -> hours) / hour |> floor} hours" -> _prettier +fn _human_minutes(time: Time) -> String = "{(time -> minutes) / minute |> floor} minutes" -> _prettier + +fn _precise_human_months(time: Time) -> String = "{(time -> months) / month } months" -> _prettier +fn _precise_human_days(time: Time) -> String = "{(time -> days) / day } days" -> _prettier +fn _precise_human_seconds(time: Time) -> String = "{(time -> seconds) / second} seconds" -> _prettier + +fn _human_recurse(t: Time, result: String, time_unit: String) -> String = + if time_unit == "day" + then _human_recurse((t -> day) - (t |> floor_in(day)), _human_join(result, t -> _human_days), "hour") + else if time_unit == "hour" + then _human_recurse((t -> hour) - (t |> floor_in(hour)), _human_join(result, t -> _human_hours), "minute") + else if time_unit == "minute" + then _human_recurse((t -> min) - (t |> floor_in(min)), _human_join(result, t -> _human_minutes), "second") + else _human_join(result, (t |> round_in(ms)) -> _precise_human_seconds) + +fn _year_month_approx(t: Time) -> String = _human_join(the_years -> _human_years, t - the_years -> _human_months) + where the_years = t |> floor_in(year) + +fn _human_manage_past(str: String, time: Time) = str_append(str, if time < 0 s then " ago" else "") + +fn _human_for_long_duration(human_days: String, human_years: String) -> String = + "{human_days} (approx. {human_years})" + +fn _abs_human(time: Time) -> String = + if time == 0 s then "0 seconds" + else if time < 60 seconds then time -> _precise_human_seconds + else if time < 2 months then _human_recurse(time, "", "day") + else if time < 1 year + then _human_for_long_duration(time -> _precise_human_days, (time |> round_in(month/10)) -> _precise_human_months) + else if time < 100 years + then _human_for_long_duration(time -> _precise_human_days, _year_month_approx(time)) + else + _human_for_long_duration(time -> _precise_human_days, time -> _human_years) -# Implementation details: -# we skip hours/minutes/seconds for durations larger than 1000 days because: -# (a) we run into floating point precision problems at the nanosecond level at this point -# (b) for much larger numbers, we can't convert to DateTimes anymore @name("Human-readable time duration") @url("https://numbat.dev/doc/date-and-time.html") @description("Converts a time duration to a human-readable string in days, hours, minutes and seconds.") @example("century/1e6 -> human", "How long is a microcentury?") -fn human(time: Time) = - if _human_num_days(time) > 1000 - then "{_human_num_days(time)} days" - else _human_readable_duration(time, datetime("0001-01-01T00:00:00Z") + time, _human_num_days(time)) +fn human(time: Time) -> String = _human_manage_past(abs(time) -> _abs_human, time)