Packages

A comprehensive calendar library for Gleam — date, time, datetime, naive datetime, duration, ISO 8601 parsing and formatting, timezone support. Port of Elixir's Calendar modules.

Current section

Files

Jump to
sidereal src calendar duration.gleam
Raw

src/calendar/duration.gleam

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2021 The Elixir Team
import gleam/int
import gleam/list
import gleam/string
/// Struct and functions for handling durations.
///
/// A `Duration` struct represents a collection of time scale units,
/// allowing for manipulation and calculation of durations.
///
/// Date and time scale units are represented as integers, allowing for
/// both positive and negative values.
///
/// Microseconds are represented using a tuple `#(microsecond, precision)`.
/// This ensures compatibility with other calendar types implementing time,
/// such as `Time`, `DateTime`, and `NaiveDateTime`.
pub type Duration {
Duration(
year: Int,
month: Int,
week: Int,
day: Int,
hour: Int,
minute: Int,
second: Int,
microsecond: #(Int, Int),
)
}
pub type UnitPair {
Year(Int)
Month(Int)
Week(Int)
Day(Int)
Hour(Int)
Minute(Int)
Second(Int)
Microsecond(#(Int, Int))
}
/// Creates a new Duration struct with all units set to zero.
pub fn new() -> Duration {
Duration(
year: 0,
month: 0,
week: 0,
day: 0,
hour: 0,
minute: 0,
second: 0,
microsecond: #(0, 0),
)
}
/// Creates a new Duration from a list of unit pairs.
pub fn from_unit_pairs(pairs: List(UnitPair)) -> Duration {
let initial = new()
list_fold_duration(pairs, initial)
}
fn list_fold_duration(pairs: List(UnitPair), acc: Duration) -> Duration {
case pairs {
[] -> acc
[Year(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, year: value))
[Month(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, month: value))
[Week(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, week: value))
[Day(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, day: value))
[Hour(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, hour: value))
[Minute(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, minute: value))
[Second(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, second: value))
[Microsecond(value), ..rest] ->
list_fold_duration(rest, Duration(..acc, microsecond: value))
}
}
/// Adds two durations together.
pub fn add(d1: Duration, d2: Duration) -> Duration {
let #(d1_ms, d1_p) = d1.microsecond
let #(d2_ms, d2_p) = d2.microsecond
let max_precision = case d1_p >= d2_p {
True -> d1_p
False -> d2_p
}
Duration(
year: d1.year + d2.year,
month: d1.month + d2.month,
week: d1.week + d2.week,
day: d1.day + d2.day,
hour: d1.hour + d2.hour,
minute: d1.minute + d2.minute,
second: d1.second + d2.second,
microsecond: #(d1_ms + d2_ms, max_precision),
)
}
/// Subtracts the second duration from the first.
pub fn subtract(d1: Duration, d2: Duration) -> Duration {
let #(d1_ms, d1_p) = d1.microsecond
let #(d2_ms, d2_p) = d2.microsecond
let max_precision = case d1_p >= d2_p {
True -> d1_p
False -> d2_p
}
Duration(
year: d1.year - d2.year,
month: d1.month - d2.month,
week: d1.week - d2.week,
day: d1.day - d2.day,
hour: d1.hour - d2.hour,
minute: d1.minute - d2.minute,
second: d1.second - d2.second,
microsecond: #(d1_ms - d2_ms, max_precision),
)
}
/// Multiplies a duration by an integer.
pub fn multiply(duration: Duration, multiplier: Int) -> Duration {
let #(ms, p) = duration.microsecond
Duration(
year: duration.year * multiplier,
month: duration.month * multiplier,
week: duration.week * multiplier,
day: duration.day * multiplier,
hour: duration.hour * multiplier,
minute: duration.minute * multiplier,
second: duration.second * multiplier,
microsecond: #(ms * multiplier, p),
)
}
/// Negates a duration (makes positive units negative and vice versa).
pub fn negate(duration: Duration) -> Duration {
let #(ms, p) = duration.microsecond
Duration(
year: -duration.year,
month: -duration.month,
week: -duration.week,
day: -duration.day,
hour: -duration.hour,
minute: -duration.minute,
second: -duration.second,
microsecond: #(-ms, p),
)
}
pub type ParseError {
InvalidFormat
InvalidDuration
}
/// Parses an ISO8601 duration string.
///
/// Examples:
/// - "P1Y2M3DT4H5M6S" -> 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds
/// - "P1W" -> 1 week
/// - "PT30M" -> 30 minutes
pub fn from_iso8601(duration_string: String) -> Result(Duration, ParseError) {
case parse_iso8601_duration(duration_string) {
Ok(duration) -> Ok(duration)
Error(_) -> Error(InvalidFormat)
}
}
/// Parses an ISO8601 duration string, panicking on invalid input.
pub fn from_iso8601_unchecked(duration_string: String) -> Duration {
case from_iso8601(duration_string) {
Ok(duration) -> duration
Error(_) -> panic as "Invalid ISO8601 duration format"
}
}
/// Converts a duration to a human-readable string.
pub fn to_string(duration: Duration) -> String {
let parts = []
let parts = case duration.year != 0 {
True -> [
int.to_string(duration.year) <> " year" <> plural(duration.year),
..parts
]
False -> parts
}
let parts = case duration.month != 0 {
True -> [
int.to_string(duration.month) <> " month" <> plural(duration.month),
..parts
]
False -> parts
}
let parts = case duration.week != 0 {
True -> [
int.to_string(duration.week) <> " week" <> plural(duration.week),
..parts
]
False -> parts
}
let parts = case duration.day != 0 {
True -> [
int.to_string(duration.day) <> " day" <> plural(duration.day),
..parts
]
False -> parts
}
let parts = case duration.hour != 0 {
True -> [
int.to_string(duration.hour) <> " hour" <> plural(duration.hour),
..parts
]
False -> parts
}
let parts = case duration.minute != 0 {
True -> [
int.to_string(duration.minute) <> " minute" <> plural(duration.minute),
..parts
]
False -> parts
}
let parts = case duration.second != 0 {
True -> [
int.to_string(duration.second) <> " second" <> plural(duration.second),
..parts
]
False -> parts
}
case parts {
[] -> "0 seconds"
_ -> string.join(list.reverse(parts), ", ")
}
}
/// Converts a duration to ISO8601 format.
pub fn to_iso8601(duration: Duration) -> String {
let date_parts = []
let date_parts = case duration.year != 0 {
True -> [int.to_string(duration.year) <> "Y", ..date_parts]
False -> date_parts
}
let date_parts = case duration.month != 0 {
True -> [int.to_string(duration.month) <> "M", ..date_parts]
False -> date_parts
}
let date_parts = case duration.week != 0 {
True -> [int.to_string(duration.week) <> "W", ..date_parts]
False -> date_parts
}
let date_parts = case duration.day != 0 {
True -> [int.to_string(duration.day) <> "D", ..date_parts]
False -> date_parts
}
let time_parts = []
let time_parts = case duration.hour != 0 {
True -> [int.to_string(duration.hour) <> "H", ..time_parts]
False -> time_parts
}
let time_parts = case duration.minute != 0 {
True -> [int.to_string(duration.minute) <> "M", ..time_parts]
False -> time_parts
}
let #(ms, precision) = duration.microsecond
let time_parts = case duration.second != 0 || ms != 0 {
True -> {
let second_str = case ms == 0 || precision == 0 {
True -> int.to_string(duration.second)
False ->
int.to_string(duration.second)
<> "."
<> format_microseconds(ms, precision)
}
[second_str <> "S", ..time_parts]
}
False -> time_parts
}
let date_part = string.join(list.reverse(date_parts), "")
let time_part = case time_parts {
[] -> ""
_ -> "T" <> string.join(list.reverse(time_parts), "")
}
case date_part == "" && time_part == "" {
True -> "PT0S"
False -> "P" <> date_part <> time_part
}
}
// Helper functions
fn plural(n: Int) -> String {
case n == 1 || n == -1 {
True -> ""
False -> "s"
}
}
fn format_microseconds(ms: Int, precision: Int) -> String {
let ms_str = int.to_string(ms)
let padded = case string.length(ms_str) < precision {
True -> string.repeat("0", precision - string.length(ms_str)) <> ms_str
False -> ms_str
}
string.slice(padded, 0, precision)
|> strip_trailing_zeros
}
fn strip_trailing_zeros(s: String) -> String {
case string.ends_with(s, "0") {
True -> strip_trailing_zeros(string.drop_end(s, 1))
False -> s
}
}
fn parse_iso8601_duration(duration_string: String) -> Result(Duration, Nil) {
case string.starts_with(duration_string, "P") {
False -> Error(Nil)
True -> {
let content = string.drop_start(duration_string, 1)
case string.split_once(content, "T") {
Ok(#(date_part, time_part)) -> {
case parse_date_part(date_part), parse_time_part(time_part) {
Ok(date_duration), Ok(time_duration) ->
Ok(merge_durations(date_duration, time_duration))
Error(_), _ -> Error(Nil)
_, Error(_) -> Error(Nil)
}
}
Error(_) -> parse_date_part(content)
}
}
}
}
fn parse_date_part(date_part: String) -> Result(Duration, Nil) {
do_parse_date_part(string.to_graphemes(date_part), "", new())
}
fn do_parse_date_part(
chars: List(String),
acc: String,
duration: Duration,
) -> Result(Duration, Nil) {
case chars {
[] ->
case acc {
"" -> Ok(duration)
_ -> Error(Nil)
}
[c, ..rest] -> {
case c {
"Y" ->
case int.parse(acc) {
Ok(n) -> do_parse_date_part(rest, "", Duration(..duration, year: n))
Error(_) -> Error(Nil)
}
"M" ->
case int.parse(acc) {
Ok(n) ->
do_parse_date_part(rest, "", Duration(..duration, month: n))
Error(_) -> Error(Nil)
}
"W" ->
case int.parse(acc) {
Ok(n) -> do_parse_date_part(rest, "", Duration(..duration, week: n))
Error(_) -> Error(Nil)
}
"D" ->
case int.parse(acc) {
Ok(n) -> do_parse_date_part(rest, "", Duration(..duration, day: n))
Error(_) -> Error(Nil)
}
_ -> do_parse_date_part(rest, acc <> c, duration)
}
}
}
}
fn parse_time_part(time_part: String) -> Result(Duration, Nil) {
do_parse_time_part(string.to_graphemes(time_part), "", new())
}
fn do_parse_time_part(
chars: List(String),
acc: String,
duration: Duration,
) -> Result(Duration, Nil) {
case chars {
[] ->
case acc {
"" -> Ok(duration)
_ -> Error(Nil)
}
[c, ..rest] -> {
case c {
"H" ->
case int.parse(acc) {
Ok(n) -> do_parse_time_part(rest, "", Duration(..duration, hour: n))
Error(_) -> Error(Nil)
}
"M" ->
case int.parse(acc) {
Ok(n) ->
do_parse_time_part(rest, "", Duration(..duration, minute: n))
Error(_) -> Error(Nil)
}
"S" -> {
// Handle fractional seconds (e.g., "1.5S")
case string.contains(acc, ".") {
True -> {
case string.split(acc, ".") {
[sec_str, frac_str] -> {
case int.parse(sec_str) {
Ok(sec) -> {
let padded = pad_to_6(frac_str)
case int.parse(padded) {
Ok(us) -> {
let precision = string.length(frac_str)
do_parse_time_part(
rest,
"",
Duration(..duration, second: sec, microsecond: #(
us,
precision,
)),
)
}
Error(_) -> Error(Nil)
}
}
Error(_) -> Error(Nil)
}
}
_ -> Error(Nil)
}
}
False ->
case int.parse(acc) {
Ok(n) ->
do_parse_time_part(rest, "", Duration(..duration, second: n))
Error(_) -> Error(Nil)
}
}
}
_ -> do_parse_time_part(rest, acc <> c, duration)
}
}
}
}
fn pad_to_6(s: String) -> String {
let len = string.length(s)
case len >= 6 {
True -> string.slice(s, 0, 6)
False -> s <> string.repeat("0", 6 - len)
}
}
fn merge_durations(d1: Duration, d2: Duration) -> Duration {
add(d1, d2)
}
/// Converts a duration to a timeout value in milliseconds.
/// Raises if the duration contains non-zero year or month values,
/// as those cannot be reliably converted.
pub fn to_timeout(duration: Duration) -> Int {
let #(microsecond_val, _precision) = duration.microsecond
let ms_from_us = microsecond_val / 1000
duration.week
* 7
* 24
* 60
* 60
* 1000
+ duration.day
* 24
* 60
* 60
* 1000
+ duration.hour
* 60
* 60
* 1000
+ duration.minute
* 60
* 1000
+ duration.second
* 1000
+ ms_from_us
}