import birl/duration import birl/zones import gleam/bool import gleam/function import gleam/int import gleam/list import gleam/option import gleam/order import gleam/regexp import gleam/result import gleam/string import gleam/time/calendar import gleam/time/duration as time_duration import gleam/time/timestamp import ranger pub opaque type Time { Time( timestamp: timestamp.Timestamp, offset: time_duration.Duration, timezone: option.Option(String), monotonic_time: option.Option(Int), ) } pub type Day { Day(year: Int, month: Int, date: Int) } pub type TimeOfDay { TimeOfDay(hour: Int, minute: Int, second: Int, nanosecond: Int) } /// Deprecated: Use the nanosecond field directly, divide by 1_000_000 for milliseconds @deprecated("Use nanosecond field directly, divide by 1_000_000 for milliseconds") pub fn get_milli_second(tod: TimeOfDay) -> Int { tod.nanosecond / 1_000_000 } /// starting point of unix timestamps pub fn unix_epoch() -> Time { Time( timestamp.from_unix_seconds(0), time_duration.seconds(0), option.None, option.None, ) } pub type Weekday { Mon Tue Wed Thu Fri Sat Sun } pub type Month { Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec } /// use this to get the current time in the local timezone offset pub fn now() -> Time { let ts = timestamp.system_time() let offset = calendar.local_offset() let monotonic_now = ffi_monotonic_now() Time( ts, offset, validate_timezone(local_timezone()), option.Some(monotonic_now), ) } /// use this to get the current time in utc pub fn utc_now() -> Time { let ts = timestamp.system_time() let monotonic_now = ffi_monotonic_now() Time( ts, time_duration.seconds(0), option.Some("Etc/UTC"), option.Some(monotonic_now), ) } /// use this to get the current time with a given offset. /// /// some examples of acceptable offsets: /// /// `"+330", "03:30", "-8:00","-7", "-0400", "03"` pub fn now_with_offset(offset: String) -> Result(Time, Nil) { use offset_seconds <- result.try(parse_offset(offset)) let ts = timestamp.system_time() let monotonic_now = ffi_monotonic_now() Time( ts, time_duration.seconds(offset_seconds), option.None, option.Some(monotonic_now), ) |> Ok } pub fn now_with_timezone(timezone: String) -> Result(Time, Nil) { use offset_seconds <- result.try(list.key_find(zones.list, timezone)) let ts = timestamp.system_time() let monotonic_now = ffi_monotonic_now() Time( ts, time_duration.seconds(offset_seconds), option.Some(timezone), option.Some(monotonic_now), ) |> Ok } pub fn monotonic_now() -> Int { ffi_monotonic_now() } /// returns if the time has passed pub fn has_occured(value: Time) -> Bool { compare(now(), value) == order.Gt } /// returns a string which is the date part of an ISO8601 string along with the offset pub fn to_date_string(value: Time) -> String { let #(#(year, month, day), _, offset) = to_parts(value) int.to_string(year) <> "-" <> pad2(month) <> "-" <> pad2(day) <> offset } /// like `to_date_string` except it does not contain the offset pub fn to_naive_date_string(value: Time) -> String { let #(#(year, month, day), _, _) = to_parts(value) int.to_string(year) <> "-" <> pad2(month) <> "-" <> pad2(day) } /// returns a string which is the time part of an ISO8601 string along with the offset pub fn to_time_string(value: Time) -> String { let #(_, #(hour, minute, second, milli_second), offset) = to_parts(value) pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> "." <> pad3(milli_second) <> offset } /// like `to_time_string` except it does not contain the offset pub fn to_naive_time_string(value: Time) -> String { let #(_, #(hour, minute, second, milli_second), _) = to_parts(value) pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> "." <> pad3(milli_second) } pub fn to_iso8601(value: Time) -> String { let #(#(year, month, day), #(hour, minute, second, milli_second), offset) = to_parts(value) int.to_string(year) <> "-" <> pad2(month) <> "-" <> pad2(day) <> "T" <> pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> "." <> pad3(milli_second) <> offset } /// if you need to parse an `ISO8601` string, this is probably what you're looking for. /// /// given the huge surface area that `ISO8601` covers, it does not make sense for `birl` /// to support all of it in one function, so this function parses only strings for which both /// day and time of day can be extracted or deduced. Some acceptable examples are given below: /// /// - `2019t14-4` -> `2019-01-01T14:00:00.000-04:00` /// /// - `2019-03-26t14:00.9z` -> `2019-03-26T14:00:00.900Z` /// /// - `2019-03-26+330` -> `2019-03-26T00:00:00.000+03:30` /// /// - `20190326t1400-4` -> `2019-03-26T14:00:00.000-04:00` /// /// - `19051222T16:38-3` -> `1905-12-22T16:38:00.000-03:00` /// /// - `2019-03-26 14:30:00.9Z` -> `2019-03-26T14:30:00.900Z` /// /// - `2019-03-26T14:00:00.9Z` -> `2019-03-26T14:00:00.900Z` /// /// - `1905-12-22 16:38:23-3` -> `1905-12-22T16:38:23.000-03:00` /// /// - `2019-03-26T14:00:00,4999Z` -> `2019-03-26T14:00:00.499Z` /// /// - `1905-12-22T163823+0330` -> `1905-12-22T16:38:23.000+03:30` /// /// - `1905-12-22T16:38:23.000+03:30` -> `1905-12-22T16:38:23.000+03:30` pub fn parse(value: String) -> Result(Time, Nil) { let assert Ok(offset_pattern) = regexp.from_string("(.*)([+|\\-].*)") let value = string.trim(value) use #(day_string, offsetted_time_string) <- result.try( case string.split(value, "T"), string.split(value, "t"), string.split(value, " ") { [day_string, time_string], _, _ | _, [day_string, time_string], _ | _, _, [day_string, time_string] -> Ok(#(day_string, time_string)) [_], [_], [_] -> Ok(#(value, "00")) _, _, _ -> Error(Nil) }, ) let day_string = string.trim(day_string) let offsetted_time_string = string.trim(offsetted_time_string) use #(day_string, time_string, offset_string) <- result.try( case string.ends_with(offsetted_time_string, "Z") || string.ends_with(offsetted_time_string, "z") { True -> Ok(#(day_string, string.drop_end(offsetted_time_string, 1), "+00:00")) False -> case regexp.scan(offset_pattern, offsetted_time_string) { [ regexp.Match( _, [option.Some(time_string), option.Some(offset_string)], ), ] -> Ok(#(day_string, time_string, offset_string)) _ -> case regexp.scan(offset_pattern, day_string) { [ regexp.Match( _, [option.Some(day_string), option.Some(offset_string)], ), ] -> Ok(#(day_string, "00", offset_string)) _ -> Error(Nil) } } }, ) let time_string = string.replace(time_string, ":", "") use #(time_string, milli_seconds_result) <- result.try( case string.split(time_string, "."), string.split(time_string, ",") { [_], [_] -> { Ok(#(time_string, Ok(0))) } [time_string, milli_seconds_string], [_] | [_], [time_string, milli_seconds_string] -> { Ok(#( time_string, milli_seconds_string |> string.slice(0, 3) |> string.pad_end(3, "0") |> int.parse, )) } _, _ -> Error(Nil) }, ) case milli_seconds_result { Ok(milli_seconds) -> { use day <- result.try(parse_date_section(day_string)) let assert [year, month, date] = day use time_of_day <- result.try(parse_time_section(time_string)) let assert [hour, minute, second] = time_of_day from_parts( #(year, month, date), #(hour, minute, second, milli_seconds), offset_string, ) } Error(Nil) -> Error(Nil) } } /// this function parses `ISO8601` strings in which no date is specified, which /// means such inputs don't actually represent a particular moment in time. That's why /// the result of this function is an instance of `TimeOfDay` along with the offset specificed /// in the string. Some acceptable examples are given below: /// /// - `t25z` -> `#(TimeOfDay(2, 5, 0, 0), "Z")` /// /// - `14-4` -> `#(TimeOfDay(14, 0, 0, 0), "-04:00")` /// /// - `T145+4` -> `#(TimeOfDay(14, 5, 0, 0), "+04:00")` /// /// - `16:38-3` -> `#(TimeOfDay(16, 38, 0, 0), "-03:00")` /// /// - `t14:65.9z` -> `#(TimeOfDay(14, 6, 5, 900), "-04:00")` /// /// - `163823+0330` -> `#(TimeOfDay(16, 38, 23, 0), "+03:30")` /// /// - `T16:38:23.050+03:30` -> `#(TimeOfDay(16, 38, 23, 50), "+03:30")` pub fn parse_time_of_day(value: String) -> Result(#(TimeOfDay, String), Nil) { let assert Ok(offset_pattern) = regexp.from_string("(.*)([+|\\-].*)") let time_string = case string.starts_with(value, "T"), string.starts_with(value, "t") { True, _ | _, True -> string.drop_start(value, 1) _, _ -> value } use #(time_string, offset_string) <- result.try( case string.ends_with(time_string, "Z") || string.ends_with(time_string, "z") { True -> Ok(#(string.drop_end(value, 1), "+00:00")) False -> case regexp.scan(offset_pattern, value) { [ regexp.Match( _, [option.Some(time_string), option.Some(offset_string)], ), ] -> Ok(#(time_string, offset_string)) _ -> Error(Nil) } }, ) let time_string = string.replace(time_string, ":", "") use #(time_string, milli_seconds_result) <- result.try( case string.split(time_string, "."), string.split(time_string, ",") { [_], [_] -> { Ok(#(time_string, Ok(0))) } [time_string, milli_seconds_string], [_] | [_], [time_string, milli_seconds_string] -> { Ok(#( time_string, milli_seconds_string |> string.slice(0, 3) |> string.pad_end(3, "0") |> int.parse, )) } _, _ -> Error(Nil) }, ) case milli_seconds_result { Ok(milli_seconds) -> { use time_of_day <- result.try(parse_time_section(time_string)) let assert [hour, minute, second] = time_of_day use offset_seconds <- result.try(parse_offset(offset_string)) use offset_string <- result.try( generate_offset(time_duration.seconds(offset_seconds)), ) // Convert milliseconds to nanoseconds for TimeOfDay Ok(#( TimeOfDay(hour, minute, second, milli_seconds * 1_000_000), offset_string, )) } Error(Nil) -> Error(Nil) } } /// accepts fromats similar to the ones listed for `parse_time_of_day` except that there shoundn't be any offset information pub fn parse_naive_time_of_day( value: String, ) -> Result(#(TimeOfDay, String), Nil) { let time_string = case string.starts_with(value, "T"), string.starts_with(value, "t") { True, _ | _, True -> string.drop_start(value, 1) _, _ -> value } let time_string = string.replace(time_string, ":", "") use #(time_string, milli_seconds_result) <- result.try( case string.split(time_string, "."), string.split(time_string, ",") { [_], [_] -> { Ok(#(time_string, Ok(0))) } [time_string, milli_seconds_string], [_] | [_], [time_string, milli_seconds_string] -> { Ok(#( time_string, milli_seconds_string |> string.slice(0, 3) |> string.pad_end(3, "0") |> int.parse, )) } _, _ -> Error(Nil) }, ) case milli_seconds_result { Ok(milli_seconds) -> { use time_of_day <- result.try(parse_time_section(time_string)) let assert [hour, minute, second] = time_of_day // Convert milliseconds to nanoseconds for TimeOfDay Ok(#(TimeOfDay(hour, minute, second, milli_seconds * 1_000_000), "Z")) } Error(Nil) -> Error(Nil) } } pub fn time_of_day_to_string(value: TimeOfDay) -> String { int.to_string(value.hour) <> ":" <> pad2(value.minute) <> ":" <> pad2(value.second) <> "." <> pad3(value.nanosecond / 1_000_000) } pub fn time_of_day_to_short_string(value: TimeOfDay) -> String { int.to_string(value.hour) <> ":" <> pad2(value.minute) } /// the naive format is the same as ISO8601 except that it does not contain the offset pub fn to_naive(value: Time) -> String { let #(#(year, month, day), #(hour, minute, second, milli_second), _) = to_parts(value) int.to_string(year) <> "-" <> pad2(month) <> "-" <> pad2(day) <> "T" <> pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> "." <> pad3(milli_second) } /// accepts fromats similar to the ones listed for `parse` except that there shoundn't be any offset information pub fn from_naive(value: String) -> Result(Time, Nil) { let value = string.trim(value) use #(day_string, time_string) <- result.try( case string.split(value, "T"), string.split(value, "t"), string.split(value, " ") { [day_string, time_string], _, _ | _, [day_string, time_string], _ | _, _, [day_string, time_string] -> Ok(#(day_string, time_string)) [_], [_], [_] -> Ok(#(value, "00")) _, _, _ -> Error(Nil) }, ) let day_string = string.trim(day_string) let time_string = string.trim(time_string) let time_string = string.replace(time_string, ":", "") use #(time_string, milli_seconds_result) <- result.try( case string.split(time_string, "."), string.split(time_string, ",") { [_], [_] -> Ok(#(time_string, Ok(0))) [time_string, milli_seconds_string], [_] | [_], [time_string, milli_seconds_string] -> Ok(#( time_string, milli_seconds_string |> string.slice(0, 3) |> string.pad_end(3, "0") |> int.parse, )) _, _ -> Error(Nil) }, ) case milli_seconds_result { Ok(milli_seconds) -> { use day <- result.try(parse_date_section(day_string)) let assert [year, month, date] = day use time_of_day <- result.try(parse_time_section(time_string)) let assert [hour, minute, second] = time_of_day from_parts( #(year, month, date), #(hour, minute, second, milli_seconds), "Z", ) } Error(Nil) -> Error(Nil) } } /// see [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date) pub fn to_http(value: Time) -> String { let assert Ok(value) = set_offset(value, "Z") let #(#(year, _, day), #(hour, minute, second, _), _) = to_parts(value) short_string_weekday(value) <> ", " <> pad2(day) <> " " <> short_string_month(value) <> " " <> int.to_string(year) <> " " <> pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> " GMT" } /// like `to_http` but assumes the offset in the DateTime value instead of `GMT` pub fn to_http_with_offset(value: Time) -> String { let #(#(year, _, day), #(hour, minute, second, _), offset) = to_parts(value) let offset = case offset { "Z" -> "GMT" _ -> offset } short_string_weekday(value) <> ", " <> pad2(day) <> " " <> short_string_month(value) <> " " <> int.to_string(year) <> " " <> pad2(hour) <> ":" <> pad2(minute) <> ":" <> pad2(second) <> " " <> offset } /// see [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date) /// /// also supports other similar formats: /// /// - `Tue, 01-Nov-2016 08:49:37 GMT` /// /// - `Tue, 01 Nov 2016 08:49:37 +0630` /// /// - `Tue, 01-November-2016 08:49:37 Z` /// /// - `Tuesday, 01-Nov-2016 08:49:37 +330` /// /// - `Tuesday, 01 November 2016 08:49:37 +06:30` pub fn from_http(value: String) -> Result(Time, Nil) { let value = string.trim(value) use #(weekday, rest) <- result.try(string.split_once(value, ",")) use <- bool.guard( !list.any(weekday_strings, fn(weekday_item) { let strings = weekday_item.1 strings.0 == weekday || strings.1 == weekday }), Error(Nil), ) let rest = string.trim(rest) let assert Ok(whitespace_pattern) = regexp.from_string("\\s+") case regexp.split(whitespace_pattern, rest) { [day_string, month_string, year_string, time_string, offset_string] -> { let time_string = string.replace(time_string, ":", "") case int.parse(day_string), list.index_map(month_strings, fn(month, index) { let strings = month.1 #(index, strings.0, strings.1) }) |> list.find(fn(month) { month.1 == month_string || month.2 == month_string }), int.parse(year_string), parse_time_section(time_string) { Ok(day), Ok(#(month_index, _, _)), Ok(year), Ok([hour, minute, second]) -> case from_parts( #(year, month_index + 1, day), #(hour, minute, second, 0), case offset_string { "GMT" -> "Z" _ -> offset_string }, ) { Ok(value) -> { let correct_weekday = string_weekday(value) let correct_short_weekday = short_string_weekday(value) case list.contains([correct_weekday, correct_short_weekday], weekday) { True -> Ok(value) False -> Error(Nil) } } Error(Nil) -> Error(Nil) } _, _, _, _ -> Error(Nil) } } [day_string, time_string, offset_string] -> case string.split(day_string, "-") { [day_string, month_string, year_string] -> { let time_string = string.replace(time_string, ":", "") case int.parse(day_string), list.index_map(month_strings, fn(month, index) { let strings = month.1 #(index, strings.0, strings.1) }) |> list.find(fn(month) { month.1 == month_string || month.2 == month_string }), int.parse(year_string), parse_time_section(time_string) { Ok(day), Ok(#(month_index, _, _)), Ok(year), Ok([hour, minute, second]) -> case from_parts( #(year, month_index + 1, day), #(hour, minute, second, 0), case offset_string { "GMT" -> "Z" _ -> offset_string }, ) { Ok(value) -> { let correct_weekday = string_weekday(value) let correct_short_weekday = short_string_weekday(value) case list.contains( [correct_weekday, correct_short_weekday], weekday, ) { True -> Ok(value) False -> Error(Nil) } } Error(Nil) -> Error(Nil) } _, _, _, _ -> Error(Nil) } } _ -> Error(Nil) } _ -> Error(Nil) } } /// unix timestamps are the number of seconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn to_unix(value: Time) -> Int { let Time(timestamp: ts, ..) = value let #(seconds, _) = timestamp.to_unix_seconds_and_nanoseconds(ts) seconds } /// unix timestamps are the number of seconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn from_unix(value: Int) -> Time { Time( timestamp.from_unix_seconds(value), time_duration.seconds(0), option.None, option.None, ) } /// unix milli timestamps are the number of milliseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn to_unix_milli(value: Time) -> Int { let Time(timestamp: ts, ..) = value let #(seconds, nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(ts) seconds * 1000 + nanoseconds / 1_000_000 } /// unix milli timestamps are the number of milliseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn from_unix_milli(value: Int) -> Time { let seconds = value / 1000 let nanoseconds = { value % 1000 } * 1_000_000 Time( timestamp.from_unix_seconds_and_nanoseconds(seconds, nanoseconds), time_duration.seconds(0), option.None, option.None, ) } /// unix micro timestamps are the number of microseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn to_unix_micro(value: Time) -> Int { let Time(timestamp: ts, ..) = value let #(seconds, nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(ts) seconds * 1_000_000 + nanoseconds / 1000 } /// unix micro timestamps are the number of microseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn from_unix_micro(value: Int) -> Time { let seconds = value / 1_000_000 let nanoseconds = { value % 1_000_000 } * 1000 Time( timestamp.from_unix_seconds_and_nanoseconds(seconds, nanoseconds), time_duration.seconds(0), option.None, option.None, ) } /// unix nano timestamps are the number of nanoseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn to_unix_nano(value: Time) -> Int { let Time(timestamp: ts, ..) = value let #(seconds, nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(ts) seconds * 1_000_000_000 + nanoseconds } /// unix nano timestamps are the number of nanoseconds that have elapsed since 00:00:00 UTC on January 1st, 1970 pub fn from_unix_nano(value: Int) -> Time { let seconds = value / 1_000_000_000 let nanoseconds = value % 1_000_000_000 Time( timestamp.from_unix_seconds_and_nanoseconds(seconds, nanoseconds), time_duration.seconds(0), option.None, option.None, ) } pub fn compare(a: Time, b: Time) -> order.Order { let Time(timestamp: tsa, monotonic_time: mta, ..) = a let Time(timestamp: tsb, monotonic_time: mtb, ..) = b // Prefer monotonic time for comparison if both have it case mta, mtb { option.Some(ma), option.Some(mb) -> int.compare(ma, mb) _, _ -> timestamp.compare(tsa, tsb) } } pub fn difference(a: Time, b: Time) -> duration.Duration { let Time(timestamp: tsa, monotonic_time: mta, ..) = a let Time(timestamp: tsb, monotonic_time: mtb, ..) = b // Prefer monotonic time for difference if both have it case mta, mtb { option.Some(ma), option.Some(mb) -> { // Monotonic time is in microseconds, convert to nanoseconds for Duration let diff_micros = ma - mb duration.micro_seconds(diff_micros) } _, _ -> timestamp.difference(tsa, tsb) } } const string_to_units = [ #("year", duration.Year), #("month", duration.Month), #("week", duration.Week), #("day", duration.Day), #("hour", duration.Hour), #("minute", duration.Minute), #("second", duration.Second), ] /// you could say this is the opposite of `legible_difference` /// /// ```gleam /// > parse_relative(birl.now(), "8 minutes ago") /// ``` pub fn parse_relative(origin: Time, legible_difference: String) { case string.split(legible_difference, " ") { ["in", amount_string, unit] | [amount_string, unit, "from now"] | [amount_string, unit, "later"] | [amount_string, unit, "ahead"] | [amount_string, unit, "in the future"] | [amount_string, unit, "hence"] -> { let unit = case string.ends_with(unit, "s") { False -> unit True -> string.drop_end(unit, 1) } use amount <- result.try(int.parse(amount_string)) use unit <- result.try(list.key_find(string_to_units, unit)) Ok(add(origin, duration.new([#(amount, unit)]))) } [amount_string, unit, "ago"] | [amount_string, unit, "before"] | [amount_string, unit, "earlier"] | [amount_string, unit, "since"] | [amount_string, unit, "in the past"] -> { let unit = case string.ends_with(unit, "s") { False -> unit True -> string.drop_end(unit, 1) } use amount <- result.try(int.parse(amount_string)) use unit <- result.try(list.key_find(string_to_units, unit)) Ok(subtract(origin, duration.new([#(amount, unit)]))) } _ -> Error(Nil) } } const units_to_string = [ #(duration.Year, "year"), #(duration.Month, "month"), #(duration.Week, "week"), #(duration.Day, "day"), #(duration.Hour, "hour"), #(duration.Minute, "minute"), #(duration.Second, "second"), ] pub fn legible_difference(a: Time, b: Time) -> String { case difference(a, b) |> duration.blur { #(_, duration.MicroSecond) | #(_, duration.MilliSecond) -> "just now" #(amount, unit) -> { let assert Ok(unit) = list.key_find(units_to_string, unit) let is_negative = amount < 0 let amount = int.absolute_value(amount) let unit = case amount { 1 -> unit _ -> unit <> "s" } case is_negative { True -> "in " <> int.to_string(amount) <> " " <> unit False -> int.to_string(amount) <> " " <> unit <> " ago" } } } } pub fn add(value: Time, dur: duration.Duration) -> Time { let Time(timestamp: ts, offset: o, timezone: timezone, monotonic_time: mt) = value let new_ts = timestamp.add(ts, dur) // Also update monotonic time if present (it's in microseconds) let dur_micros = duration_to_microseconds(dur) let new_mt = option.map(mt, fn(m) { m + dur_micros }) Time(timestamp: new_ts, offset: o, timezone: timezone, monotonic_time: new_mt) } pub fn subtract(value: Time, dur: duration.Duration) -> Time { let Time(timestamp: ts, offset: o, timezone: timezone, monotonic_time: mt) = value // gleam_time doesn't have subtract, so negate the duration and add // difference(a, b) = b - a, so difference(dur, 0) = 0 - dur = -dur let negated_dur = time_duration.difference(dur, time_duration.seconds(0)) let new_ts = timestamp.add(ts, negated_dur) // Also update monotonic time if present (it's in microseconds) let dur_micros = duration_to_microseconds(dur) let new_mt = option.map(mt, fn(m) { m - dur_micros }) Time(timestamp: new_ts, offset: o, timezone: timezone, monotonic_time: new_mt) } pub fn weekday(value: Time) -> Weekday { let Time(timestamp: ts, offset: offset, ..) = value // Get unix timestamp in microseconds and offset in microseconds for FFI let #(seconds, nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(ts) let micros = seconds * 1_000_000 + nanoseconds / 1000 let #(offset_seconds, _) = time_duration.to_seconds_and_nanoseconds(offset) let offset_micros = offset_seconds * 1_000_000 let assert Ok(wd) = weekday_from_int(ffi_weekday(micros, offset_micros)) wd } pub fn string_weekday(value: Time) -> String { weekday(value) |> weekday_to_string } pub fn short_string_weekday(value: Time) -> String { weekday(value) |> weekday_to_short_string } pub fn weekday_to_string(value: Weekday) -> String { case value { Mon -> "Monday" Tue -> "Tuesday" Wed -> "Wednesday" Thu -> "Thursday" Fri -> "Friday" Sat -> "Saturday" Sun -> "Sunday" } } pub fn weekday_to_short_string(value: Weekday) -> String { case value { Mon -> "Mon" Tue -> "Tue" Wed -> "Wed" Thu -> "Thu" Fri -> "Fri" Sat -> "Sat" Sun -> "Sun" } } pub fn parse_weekday(value: String) -> Result(Weekday, Nil) { let lowercase = string.lowercase(value) let weekday = list.find(weekday_strings, fn(weekday_string) { let #(_, #(long, short)) = weekday_string lowercase == string.lowercase(short) || lowercase == string.lowercase(long) }) weekday |> result.map(fn(weekday) { weekday.0 }) } pub fn parse_month(value: String) -> Result(Month, Nil) { let lowercase = string.lowercase(value) let month = list.find(month_strings, fn(month_string) { let #(_, #(long, short)) = month_string lowercase == string.lowercase(short) || lowercase == string.lowercase(long) }) month |> result.map(fn(month) { month.0 }) } pub fn month(value: Time) -> Month { let #(#(_, month, _), _, _) = to_parts(value) let assert Ok(month) = month_from_int(month) month } pub fn string_month(value: Time) -> String { case month(value) { Jan -> "January" Feb -> "February" Mar -> "March" Apr -> "April" May -> "May" Jun -> "June" Jul -> "July" Aug -> "August" Sep -> "September" Oct -> "October" Nov -> "November" Dec -> "December" } } pub fn short_string_month(value: Time) -> String { case month(value) { Jan -> "Jan" Feb -> "Feb" Mar -> "Mar" Apr -> "Apr" May -> "May" Jun -> "Jun" Jul -> "Jul" Aug -> "Aug" Sep -> "Sep" Oct -> "Oct" Nov -> "Nov" Dec -> "Dec" } } /// can be used to create a time range starting from time `a` with step `s` /// /// if `b` is `option.None` the range will be infinite pub fn range(from a: Time, to b: option.Option(Time), step s: duration.Duration) { let assert Ok(range) = case b { option.Some(b) -> ranger.create( validate: fn(_) { True }, negate_step: fn(dur) { time_duration.difference(dur, time_duration.seconds(0)) }, add: add, compare: compare, )(a, b, s) option.None -> ranger.create_infinite( validate: fn(_) { True }, add: add, compare: compare, )(a, s) } range } /// WARNING: Does not respect daylight saving time! pub fn set_timezone(value: Time, new_timezone: String) -> Result(Time, Nil) { use new_offset_seconds <- result.try(list.key_find(zones.list, new_timezone)) let Time(timestamp: ts, monotonic_time: mt, ..) = value Time( ts, time_duration.seconds(new_offset_seconds), option.Some(new_timezone), mt, ) |> Ok } pub fn get_timezone(value: Time) -> option.Option(String) { let Time(timezone: timezone, ..) = value timezone } /// use this to change the offset of a given time value. /// /// some examples of acceptable offsets: /// /// `"+330", "03:30", "-8:00","-7", "-0400", "03", "Z"` pub fn set_offset(value: Time, new_offset: String) -> Result(Time, Nil) { use new_offset_seconds <- result.try(parse_offset(new_offset)) let Time(timestamp: ts, timezone: timezone, monotonic_time: mt, ..) = value Time(ts, time_duration.seconds(new_offset_seconds), timezone, mt) |> Ok } pub fn get_offset(value: Time) -> String { let Time(offset: offset, ..) = value let assert Ok(offset_str) = generate_offset(offset) offset_str } /// Get the offset as a gleam_time Duration pub fn get_offset_duration(value: Time) -> time_duration.Duration { let Time(offset: offset, ..) = value offset } pub fn set_day(value: Time, day: Day) -> Time { let #(_, time, offset_str) = to_parts(value) let Day(year, month, date) = day let assert Ok(new_value) = from_parts(#(year, month, date), time, offset_str) Time( new_value.timestamp, new_value.offset, value.timezone, value.monotonic_time, ) } pub fn get_day(value: Time) -> Day { let #(#(year, month, day), _, _) = to_parts(value) Day(year, month, day) } pub fn set_time_of_day(value: Time, time: TimeOfDay) -> Time { let #(date, _, offset_str) = to_parts(value) // TimeOfDay stores nanoseconds; convert to milliseconds for from_parts let TimeOfDay(hour, minute, second, nanosecond) = time let milli_second = nanosecond / 1_000_000 let assert Ok(new_value) = from_parts(date, #(hour, minute, second, milli_second), offset_str) Time( new_value.timestamp, new_value.offset, value.timezone, value.monotonic_time, ) } pub fn get_time_of_day(value: Time) -> TimeOfDay { let #(_, #(hour, minute, second, milli_second), _) = to_parts(value) // Convert milliseconds to nanoseconds for TimeOfDay TimeOfDay(hour, minute, second, milli_second * 1_000_000) } @target(erlang) /// calculates erlang datetime using the offset in the DateTime value pub fn to_erlang_datetime(value: Time) -> #(#(Int, Int, Int), #(Int, Int, Int)) { let #(date, #(hour, minute, second, _), _) = to_parts(value) #(date, #(hour, minute, second)) } @target(erlang) /// calculates the universal erlang datetime regardless of the offset in the DateTime value pub fn to_erlang_universal_datetime( value: Time, ) -> #(#(Int, Int, Int), #(Int, Int, Int)) { let assert Ok(value) = set_offset(value, "Z") let #(date, #(hour, minute, second, _), _) = to_parts(value) #(date, #(hour, minute, second)) } @target(erlang) /// calculates the DateTime value from the erlang datetime using the local offset of the system pub fn from_erlang_local_datetime( erlang_datetime: #(#(Int, Int, Int), #(Int, Int, Int)), ) -> Time { let #(date, time) = erlang_datetime let offset = calendar.local_offset() let base = unix_epoch() |> set_day(Day(date.0, date.1, date.2)) |> set_time_of_day(TimeOfDay(time.0, time.1, time.2, 0)) Time(base.timestamp, offset, validate_timezone(local_timezone()), option.None) } @target(erlang) /// calculates the DateTime value from the erlang datetime in UTC pub fn from_erlang_universal_datetime( erlang_datetime: #(#(Int, Int, Int), #(Int, Int, Int)), ) -> Time { let #(date, time) = erlang_datetime let assert Ok(new_value) = unix_epoch() |> set_day(Day(date.0, date.1, date.2)) |> set_time_of_day(TimeOfDay(time.0, time.1, time.2, 0)) |> set_timezone("Etc/UTC") new_value } fn from_parts( date: #(Int, Int, Int), time: #(Int, Int, Int, Int), offset: String, ) -> Result(Time, Nil) { use offset_seconds <- result.try(parse_offset(offset)) let #(year, month, day) = date let #(hour, minute, second, milli_second) = time // Convert to gleam_time types use gleam_month <- result.try( calendar.month_from_int(month) |> result.replace_error(Nil), ) let calendar_date = calendar.Date(year, gleam_month, day) // Store nanoseconds (milliseconds * 1_000_000) let calendar_time = calendar.TimeOfDay(hour, minute, second, milli_second * 1_000_000) let offset_duration = time_duration.seconds(offset_seconds) let ts = timestamp.from_calendar(calendar_date, calendar_time, offset_duration) Time(ts, offset_duration, option.None, option.None) |> Ok } fn to_parts(value: Time) -> #(#(Int, Int, Int), #(Int, Int, Int, Int), String) { let Time(timestamp: ts, offset: offset, ..) = value let #(date, time) = timestamp.to_calendar(ts, offset) let month_int = calendar.month_to_int(date.month) // Convert nanoseconds to milliseconds let milli_second = time.nanoseconds / 1_000_000 let assert Ok(offset_string) = generate_offset(offset) #( #(date.year, month_int, date.day), #(time.hours, time.minutes, time.seconds, milli_second), offset_string, ) } /// Parse an offset string and return the offset in seconds fn parse_offset(offset: String) -> Result(Int, Nil) { use <- bool.guard(list.contains(["Z", "z"], offset), Ok(0)) let assert Ok(re) = regexp.from_string("([+-])") use #(sign, offset) <- result.try(case regexp.split(re, offset) { ["", "+", offset] -> Ok(#(1, offset)) ["", "-", offset] -> Ok(#(-1, offset)) [_] -> Ok(#(1, offset)) _ -> Error(Nil) }) case string.split(offset, ":") { [hour_str, minute_str] -> { use hour <- result.try(int.parse(hour_str)) use minute <- result.try(int.parse(minute_str)) Ok(sign * { hour * 60 + minute } * 60) } [offset] -> case string.length(offset) { 1 -> { use hour <- result.try(int.parse(offset)) Ok(sign * hour * 3600) } 2 -> { use number <- result.try(int.parse(offset)) case number < 14 { True -> Ok(sign * number * 3600) False -> Ok(sign * { number / 10 * 60 + number % 10 } * 60) } } 3 -> { let assert Ok(hour_str) = string.first(offset) let minute_str = string.slice(offset, 1, 2) use hour <- result.try(int.parse(hour_str)) use minute <- result.try(int.parse(minute_str)) Ok(sign * { hour * 60 + minute } * 60) } 4 -> { let hour_str = string.slice(offset, 0, 2) let minute_str = string.slice(offset, 2, 2) use hour <- result.try(int.parse(hour_str)) use minute <- result.try(int.parse(minute_str)) Ok(sign * { hour * 60 + minute } * 60) } _ -> Error(Nil) } _ -> Error(Nil) } } fn pad2(value: Int) -> String { value |> int.to_string |> string.pad_start(2, "0") } fn pad3(value: Int) -> String { value |> int.to_string |> string.pad_start(3, "0") } /// Validates that a timezone string from the system is a known timezone fn validate_timezone(timezone: option.Option(String)) -> option.Option(String) { timezone |> option.map(fn(tz) { case list.any(zones.list, fn(item) { item.0 == tz }) { True -> option.Some(tz) False -> option.None } }) |> option.flatten } fn duration_to_microseconds(dur: time_duration.Duration) -> Int { let #(seconds, nanoseconds) = time_duration.to_seconds_and_nanoseconds(dur) seconds * 1_000_000 + nanoseconds / 1000 } fn generate_offset(offset: time_duration.Duration) -> Result(String, Nil) { let #(total_seconds, _) = time_duration.to_seconds_and_nanoseconds(offset) use <- bool.guard(total_seconds == 0, Ok("Z")) let abs_seconds = int.absolute_value(total_seconds) let sign = case total_seconds < 0 { True -> "-" False -> "+" } Ok( sign <> pad2(abs_seconds / 3600) <> ":" <> pad2({ abs_seconds % 3600 } / 60), ) } fn parse_date_section(date: String) -> Result(List(Int), Nil) { use <- bool.guard(is_invalid_date(date), Error(Nil)) case string.contains(date, "-") { True -> { let assert Ok(dash_pattern) = regexp.from_string( "(\\d{4})(?:-(1[0-2]|0?[0-9]))?(?:-(3[0-1]|[1-2][0-9]|0?[0-9]))?", ) case regexp.scan(dash_pattern, date) { [regexp.Match(_, [option.Some(major)])] -> [ int.parse(major), Ok(1), Ok(1), ] [regexp.Match(_, [option.Some(major), option.Some(middle)])] -> [ int.parse(major), int.parse(middle), Ok(1), ] [ regexp.Match( _, [option.Some(major), option.Some(middle), option.Some(minor)], ), ] -> [int.parse(major), int.parse(middle), int.parse(minor)] _ -> [Error(Nil)] } } False -> parse_section( date, "(\\d{4})(1[0-2]|0?[0-9])?(3[0-1]|[1-2][0-9]|0?[0-9])?", 1, ) } |> list.try_map(function.identity) } fn parse_time_section(time: String) -> Result(List(Int), Nil) { use <- bool.guard(is_invalid_time(time), Error(Nil)) parse_section( time, "(2[0-3]|1[0-9]|0?[0-9])([1-5][0-9]|0?[0-9])?([1-5][0-9]|0?[0-9])?", 0, ) |> list.try_map(function.identity) } fn is_invalid_date(date: String) -> Bool { date |> string.to_utf_codepoints |> list.map(string.utf_codepoint_to_int) |> list.any(fn(code) { // Only allow digits (48-57) and hyphen (45) code != 45 && { code < 48 || code > 57 } }) } fn is_invalid_time(time: String) -> Bool { time |> string.to_utf_codepoints |> list.map(string.utf_codepoint_to_int) |> list.any(fn(code) { // Only allow digits (48-57) and colon (58) code < 48 || code > 58 }) } fn parse_section( section: String, pattern_string: String, default: Int, ) -> List(Result(Int, Nil)) { let assert Ok(pattern) = regexp.from_string(pattern_string) case regexp.scan(pattern, section) { [regexp.Match(_, [option.Some(major)])] -> [ int.parse(major), Ok(default), Ok(default), ] [regexp.Match(_, [option.Some(major), option.Some(middle)])] -> [ int.parse(major), int.parse(middle), Ok(default), ] [ regexp.Match( _, [option.Some(major), option.Some(middle), option.Some(minor)], ), ] -> [int.parse(major), int.parse(middle), int.parse(minor)] _ -> [Error(Nil)] } } @target(erlang) fn weekday_from_int(weekday: Int) -> Result(Weekday, Nil) { case weekday { 0 -> Ok(Mon) 1 -> Ok(Tue) 2 -> Ok(Wed) 3 -> Ok(Thu) 4 -> Ok(Fri) 5 -> Ok(Sat) 6 -> Ok(Sun) _ -> Error(Nil) } } @target(javascript) fn weekday_from_int(weekday: Int) -> Result(Weekday, Nil) { case weekday { 0 -> Ok(Sun) 1 -> Ok(Mon) 2 -> Ok(Tue) 3 -> Ok(Wed) 4 -> Ok(Thu) 5 -> Ok(Fri) 6 -> Ok(Sat) _ -> Error(Nil) } } fn month_from_int(month: Int) -> Result(Month, Nil) { case month { 1 -> Ok(Jan) 2 -> Ok(Feb) 3 -> Ok(Mar) 4 -> Ok(Apr) 5 -> Ok(May) 6 -> Ok(Jun) 7 -> Ok(Jul) 8 -> Ok(Aug) 9 -> Ok(Sep) 10 -> Ok(Oct) 11 -> Ok(Nov) 12 -> Ok(Dec) _ -> Error(Nil) } } const weekday_strings = [ #(Mon, #("Monday", "Mon")), #(Tue, #("Tuesday", "Tue")), #(Wed, #("Wednesday", "Wed")), #(Thu, #("Thursday", "Thu")), #(Fri, #("Friday", "Fri")), #(Sat, #("Saturday", "Sat")), #(Sun, #("Sunday", "Sun")), ] const month_strings = [ #(Jan, #("January", "Jan")), #(Feb, #("February", "Feb")), #(Mar, #("March", "Mar")), #(Apr, #("April", "Apr")), #(May, #("May", "May")), #(Jun, #("June", "Jun")), #(Jul, #("July", "Jul")), #(Aug, #("August", "Aug")), #(Sep, #("September", "Sep")), #(Oct, #("October", "Oct")), #(Nov, #("November", "Nov")), #(Dec, #("December", "Dec")), ] // --------------------------------------------------------------------------- // FFI functions for features gleam_time doesn't provide // --------------------------------------------------------------------------- @external(erlang, "birl_ffi", "monotonic_now") @external(javascript, "./birl_ffi.mjs", "monotonic_now") fn ffi_monotonic_now() -> Int @external(erlang, "birl_ffi", "weekday") @external(javascript, "./birl_ffi.mjs", "weekday") fn ffi_weekday(timestamp_micros: Int, offset_micros: Int) -> Int @external(erlang, "birl_ffi", "local_timezone") @external(javascript, "./birl_ffi.mjs", "local_timezone") fn local_timezone() -> option.Option(String) // --------------------------------------------------------------------------- // gleam_time interoperability // --------------------------------------------------------------------------- /// Convert birl Time to gleam_time Timestamp. /// /// Note: This conversion loses offset/timezone information since Timestamp /// represents an absolute point in time (like UTC). pub fn to_timestamp(value: Time) -> timestamp.Timestamp { let Time(timestamp: ts, ..) = value ts } /// Alias for to_timestamp, using the to_gleam_* naming pattern pub fn to_gleam_timestamp(value: Time) -> timestamp.Timestamp { to_timestamp(value) } /// Convert gleam_time Timestamp to birl Time. /// /// The resulting Time will be in UTC with no timezone information. pub fn from_timestamp(ts: timestamp.Timestamp) -> Time { Time(ts, time_duration.seconds(0), option.Some("Etc/UTC"), option.None) } /// Alias for from_timestamp, using the from_gleam_* naming pattern pub fn from_gleam_timestamp(ts: timestamp.Timestamp) -> Time { from_timestamp(ts) } /// Convert birl Day to gleam_time calendar.Date. pub fn day_to_date(day: Day) -> calendar.Date { let assert Ok(month) = calendar.month_from_int(day.month) calendar.Date(day.year, month, day.date) } /// Convert gleam_time calendar.Date to birl Day. pub fn date_to_day(date: calendar.Date) -> Day { Day(date.year, calendar.month_to_int(date.month), date.day) } /// Convert birl TimeOfDay to gleam_time calendar.TimeOfDay. pub fn time_of_day_to_calendar(tod: TimeOfDay) -> calendar.TimeOfDay { calendar.TimeOfDay(tod.hour, tod.minute, tod.second, tod.nanosecond) } /// Convert gleam_time calendar.TimeOfDay to birl TimeOfDay. pub fn calendar_to_time_of_day(tod: calendar.TimeOfDay) -> TimeOfDay { TimeOfDay(tod.hours, tod.minutes, tod.seconds, tod.nanoseconds) }