Time
Calendar dates, wall-clock times, and instants.
Three civil types, each a plain record:
Date a calendar day, no time and no zone (2026-07-30)
Time a time of day, no date and no zone (14:03:00)
DateTime both, plus a fixed offset from UTC (2026-07-30T14:03:00+02:00)
Two span types connect them, and which one you want depends on whether the calendar gets a say:
Duration fixed elapsed time, a count of seconds (36.hours, 10.days)
Period a calendar step, resolved by the calendar (1.months, 2.years)
36.hours is always 129600 seconds; 1.months is however long that particular month turns out to be. So date + 1.months clamps January 31st to the last day of February, while date + 30.days counts thirty days.
A time Measure such as 5.second is a third thing and deliberately NOT a Duration: a Measure describes a measurement, a Duration describes elapsed time. The plural 5.seconds builds the Duration.
Values are built through their own module and used through functions:
let due = Date.of(2026, 7, 30).try # Date
due.weekday.name # "Thursday"
(due + 10.days).iso # "2026-08-09"
(due + 1.months).iso # "2026-08-30"
Time.now().iso # e.g. "14:03:00"
Anything that reads the clock is mockable: see the test clock section in module Time for Time.frozenAt.
Zones are fixed offsets: UTC, an explicit +02:00, or whatever this machine's zone resolves to at a given instant. Named IANA zones and their DST rules are not modeled: Time.now() asks the host for the offset in effect at that moment, so it is right now, but it cannot say what the offset WILL be for some future local time.
The records and the two ADTs stay at file level so make blocks, callers, and every module here can see them.
type Weekday
Weekday names in ISO order (Monday is day 1).
Variants
MondayTuesdayWednesdayThursdayFridaySaturdaySunday
type TimeError
A field out of range, or text that is not a date/time.
Variants
InvalidDate(Integer, Integer, Integer)InvalidTime(Integer, Integer, Integer)InvalidFormat(String)
record Date
A calendar day: a year, a month and a day, with no time and no zone.
let due = Date.of(2026, 7, 30).try
due.iso # => "2026-07-30"
due.weekday.name # => "Thursday"
(due + 10.days).iso # => "2026-08-09"
Build one with Date.of, which validates, rather than with the record literal, which does not.
Fields
Implements Inspectable, Showable.
Functions
inspectValue Inspectable
inspectValue(colors: Bool) -> String
Renders the date structurally, for debugging output.
Parameters
colors- whether to include ANSI color escapes
Returns the rendered date
showValue Showable
showValue : String
Renders the date in ISO form, so interpolation and IO.printLine show 2026-07-30.
Returns the ISO 8601 date
Examples
"due ${Date.of(2026, 7, 30).try}" # => "due 2026-07-30"
epochDay
epochDay : Integer
Days since 1970-01-01, negative before it.
The date's identity as a number: comparison and day arithmetic go through it.
Returns days since the Unix epoch
Examples
Date.of(1970, 1, 1).try.epochDay # => 0
Date.of(2026, 7, 30).try.epochDay # => 20664
weekday
weekday : Weekday
The day of the week this date falls on.
Returns the day of the week
Examples
Date.of(2026, 7, 30).try.weekday # => Thursday
Date.of(2026, 7, 30).try.weekday.name # => "Thursday"
dayOfYear
dayOfYear : Integer
The day's position in its year, counting from 1 on January 1st.
Returns the day of the year
Examples
Date.of(2026, 1, 1).try.dayOfYear # => 1
Date.of(2026, 7, 30).try.dayOfYear # => 211
leapYear?
leapYear? : Bool
Returns true when this date falls in a leap year.
Returns true in a leap year
Examples
Date.of(2024, 1, 1).try.leapYear? # => true
Date.of(2026, 1, 1).try.leapYear? # => false
daysInMonth
daysInMonth : Integer
The number of days in THIS date's month.
No Result: a Date's month is in range by construction, unlike Time.daysInMonth's two loose integers.
Returns the length of this month
Examples
Date.of(2024, 2, 1).try.daysInMonth # => 29
Date.of(2026, 7, 1).try.daysInMonth # => 31
+
+(span: Duration) -> Date
Advances the date by a fixed span, whole days only.
A Duration with a sub-day remainder truncates toward zero, so +date + 36.hours+ advances exactly one day. Use a Period when the calendar should get a say.
Parameters
span- the elapsed span to add
Returns the later date
Examples
(Date.of(2026, 7, 30).try + 10.days).iso # => "2026-08-09"
(Date.of(2026, 7, 30).try + 36.hours).iso # => "2026-07-31"
-
-(span: Duration) -> Date
Moves the date back by a fixed span, whole days only.
Parameters
span- the elapsed span to subtract
Returns the earlier date
Examples
(Date.of(2026, 7, 30).try - 10.days).iso # => "2026-07-20"
addDays
addDays(count: Integer) -> Date
The date count days later. A negative count moves backwards.
Parameters
count- how many days to add
Returns the shifted date
Examples
Date.of(2026, 7, 30).try.addDays(1).iso # => "2026-07-31"
Date.of(2026, 7, 30).try.addDays(-1).iso # => "2026-07-29"
addWeeks
addWeeks(count: Integer) -> Date
The date count weeks later. A negative count moves backwards.
Parameters
count- how many weeks to add
Returns the shifted date
Examples
Date.of(2026, 7, 30).try.addWeeks(2).iso # => "2026-08-13"
addMonths
addMonths(count: Integer) -> Date
No date.tomorrow/date.yesterday functions: on BEAM a make-block function flattens onto the same name as the Date.tomorrow()/Date.yesterday() module functions above and one of the two has to win. The module functions win: Date.tomorrow() is the spelling people reach for, and date.addDays(1) already says the rest.
The date count calendar months later, with the day clamped into the target month.
One month after January 31st is the last day of February, not March 3rd. A negative count moves backwards.
Parameters
count- how many months to add
Returns the shifted date
Examples
Date.of(2026, 1, 31).try.addMonths(1).iso # => "2026-02-28"
Date.of(2026, 7, 30).try.addMonths(-1).iso # => "2026-06-30"
addYears
addYears(count: Integer) -> Date
The date count calendar years later, with the day clamped: February 29th plus one year is February 28th.
Parameters
count- how many years to add
Returns the shifted date
Examples
Date.of(2024, 2, 29).try.addYears(1).iso # => "2025-02-28"
startOfMonth
startOfMonth : Date
The first day of this date's month.
Returns the first of the month
Examples
Date.of(2026, 7, 30).try.startOfMonth.iso # => "2026-07-01"
endOfMonth
endOfMonth : Date
The last day of this date's month, whatever its length.
Returns the last of the month
Examples
Date.of(2026, 7, 30).try.endOfMonth.iso # => "2026-07-31"
Date.of(2024, 2, 1).try.endOfMonth.iso # => "2024-02-29"
startOfYear
startOfYear : Date
January 1st of this date's year.
Returns the first of the year
Examples
Date.of(2026, 7, 30).try.startOfYear.iso # => "2026-01-01"
endOfYear
endOfYear : Date
December 31st of this date's year.
Returns the last of the year
Examples
Date.of(2026, 7, 30).try.endOfYear.iso # => "2026-12-31"
startOfWeek
startOfWeek : Date
The Monday of this date's week.
The week runs Monday to Sunday, matching the ISO weekday numbering weekday.number reports.
Returns the Monday of this week
Examples
Date.of(2026, 7, 30).try.startOfWeek.iso # => "2026-07-27"
endOfWeek
endOfWeek : Date
The Sunday of this date's week.
Returns the Sunday of this week
Examples
Date.of(2026, 7, 30).try.endOfWeek.iso # => "2026-08-02"
daysUntil
daysUntil(other: Date) -> Integer
Whole days from this date to other, negative when other is earlier.
Parameters
other- the date to measure to
Returns the number of days
Examples
Date.of(2026, 7, 30).try.daysUntil(Date.of(2026, 8, 9).try) # => 10
Date.of(2026, 8, 9).try.daysUntil(Date.of(2026, 7, 30).try) # => -10
until
until(other: Date) -> Duration
The span from this date to other, as a Duration of whole days.
Parameters
other- the date to measure to
Returns the elapsed span
Examples
Date.of(2026, 7, 30).try.until(Date.of(2026, 8, 9).try).wholeDays # => 10
monthsUntil
monthsUntil(other: Date) -> Integer
Whole calendar months from this date to other, negative when other is earlier.
Truncated, not rounded: a partial month does not count, so January 15th to February 14th is 0 months.
The count is the exact inverse of addMonths, which is why the correction below asks addMonths rather than comparing day-of-month fields: January 31st plus one month IS February 28th, so January 31st to February 28th is one month, even though 28 < 31. Comparing the day fields answers 0 there and contradicts the addition this same file performs.
Parameters
other- the date to measure to
Returns the number of whole months
Examples
Date.of(2026, 1, 15).try.monthsUntil(Date.of(2026, 2, 14).try) # => 0
Date.of(2026, 1, 31).try.monthsUntil(Date.of(2026, 2, 28).try) # => 1
yearsUntil
yearsUntil(other: Date) -> Integer
Whole calendar years from this date to other, negative when other is earlier. Truncated, like monthsUntil.
This is how to compute an age.
Parameters
other- the date to measure to
Returns the number of whole years
Examples
Date.of(2020, 1, 1).try.yearsUntil(Date.of(2026, 7, 30).try) # => 6
Someone's age today
born.yearsUntil(Date.today())
before?
before?(other: Date) -> Bool
Returns true when this date is earlier than other.
Parameters
other- the date to compare against
Returns true when this date comes first
Examples
Date.of(2026, 7, 30).try.before?(Date.of(2026, 8, 1).try) # => true
after?
after?(other: Date) -> Bool
Returns true when this date is later than other.
Parameters
other- the date to compare against
Returns true when this date comes second
Examples
Date.of(2026, 8, 1).try.after?(Date.of(2026, 7, 30).try) # => true
compareTo
compareTo(other: Date) -> Ordering
Orders this date against another.
Parameters
other- the date to compare against
Returns Less, Equal or Greater
Examples
Date.of(2026, 7, 30).try.compareTo(Date.of(2026, 8, 1).try) # => Less
Sorting dates
dates.sort { |a, b| a.compareTo(b) == Less }
From Showable
record Time
A time of day, with no date and no zone.
let t = Time.of(14, 3, 0).try
t.iso # => "14:03:00"
(t + 2.hours).iso # => "16:03:00"
Arithmetic wraps within the day: there is no date to carry into. Reach for DateTime when the day rolling over matters.
Fields
Implements Inspectable, Showable.
Functions
inspectValue Inspectable
inspectValue(colors: Bool) -> String
Renders the time structurally, for debugging output.
Parameters
colors- whether to include ANSI color escapes
Returns the rendered time
showValue Showable
showValue : String
Renders the time in ISO form, so interpolation and IO.printLine show 14:03:00.
Returns the ISO 8601 time
Examples
"starts at ${Time.of(14, 3, 0).try}" # => "starts at 14:03:00"
secondsSinceMidnight
secondsSinceMidnight : Integer
Seconds from midnight to this time of day, ignoring the nanosecond field.
Returns seconds since midnight
Examples
Time.of(14, 3, 0).try.secondsSinceMidnight # => 50580
before?
before?(other: Time) -> Bool
Returns true when this time of day is earlier than other.
Parameters
other- the time to compare against
Returns true when this time comes first
Examples
Time.of(14, 3, 0).try.before?(Time.of(15, 0, 0).try) # => true
after?
after?(other: Time) -> Bool
Returns true when this time of day is later than other.
Parameters
other- the time to compare against
Returns true when this time comes second
Examples
Time.of(15, 0, 0).try.after?(Time.of(14, 3, 0).try) # => true
+
+(span: Duration) -> Time
Advances the time of day by a span, wrapping within the day.
A Time has no date to carry into, so 23:00 + 2.hours is 01:00. Reach for DateTime when the day rolling over is something you need to see.
The nanosecond field rides along untouched: wholeSeconds truncates the span, so a sub-second Duration shifts nothing.
Parameters
span- the elapsed span to add
Returns the later time of day
Examples
(Time.of(14, 3, 0).try + 2.hours).iso # => "16:03:00"
(Time.of(23, 0, 0).try + 2.hours).iso # => "01:00:00"
-
-(span: Duration) -> Time
Moves the time of day back by a span, wrapping within the day.
Parameters
span- the elapsed span to subtract
Returns the earlier time of day
Examples
(Time.midnight() - 1.seconds).iso # => "23:59:59"
addSeconds
addSeconds(count: Integer) -> Time
The time of day count seconds later, wrapping within the day.
Parameters
count- how many seconds to add
Returns the shifted time of day
Examples
Time.of(14, 3, 0).try.addSeconds(60).iso # => "14:04:00"
addMinutes
addMinutes(count: Integer) -> Time
The time of day count minutes later, wrapping within the day.
Parameters
count- how many minutes to add
Returns the shifted time of day
Examples
Time.of(14, 3, 0).try.addMinutes(30).iso # => "14:33:00"
addHours
addHours(count: Integer) -> Time
The time of day count hours later, wrapping within the day.
Parameters
count- how many hours to add
Returns the shifted time of day
Examples
Time.of(23, 0, 0).try.addHours(2).iso # => "01:00:00"
until
until(other: Time) -> Duration
Elapsed time from this time of day to other, within the same day.
Negative when other is earlier. Sub-second precision is kept.
Parameters
other- the time to measure to
Returns the elapsed span
Examples
Time.of(14, 3, 0).try.until(Time.of(15, 3, 0).try).wholeMinutes # => 60
From Showable
record DateTime
An instant: a calendar date, a time of day, and a fixed offset from UTC.
let m = DateTime.parse("2026-07-30T14:03:00+02:00").try
m.iso # => "2026-07-30T14:03:00+02:00"
m.utc.iso # => "2026-07-30T12:03:00Z"
Two DateTime values that name the same instant compare equal whatever offsets they are written at: comparison goes through epochSeconds.
Fields
Implements Inspectable, Showable.
Functions
inspectValue Inspectable
inspectValue(colors: Bool) -> String
Renders the instant structurally, for debugging output.
Parameters
colors- whether to include ANSI color escapes
Returns the rendered instant
showValue Showable
showValue : String
Renders the instant in ISO form, so interpolation and IO.printLine show +2026-07-30T14:03:00+02:00+.
Returns the ISO 8601 instant
Examples
"logged at ${DateTime.utcNow()}" # => "logged at 2026-07-30T12:03:00Z"
epochSeconds
epochSeconds : Integer
Seconds since the Unix epoch.
This is the value to compare and subtract: it identifies the instant, independent of the offset rendering it, so 12:00Z and 14:00+02:00 have the same one.
Returns seconds since the Unix epoch
Examples
DateTime.parse("1970-01-01T00:00:00Z").try.epochSeconds # => 0
Storing an instant as a number
FS.File.write("stamp", "${DateTime.utcNow().epochSeconds}")
weekday
weekday : Weekday
The day of the week this instant falls on, at its own offset.
No year/hour/... shorthands here: a function named after a record field makes value.year inside a module function dispatch to it on BEAM and fail with function_clause. Reach through .date and .time.
Returns the day of the week
Examples
DateTime.parse("2026-07-30T14:03:00Z").try.weekday.name # => "Thursday"
at
at(offset: Duration) -> DateTime
The same instant, rendered at another offset.
Nothing moves: the wall clock changes because the offset does, and epochSeconds is unchanged.
Parameters
offset- the offset to render at
Returns the same instant, at that offset
Examples
DateTime.parse("2026-07-30T14:03:00+02:00").try
.at(Duration.utcOffset(-5, 0)).iso
# => "2026-07-30T07:03:00-05:00"
utc
utc : DateTime
The same instant, rendered at UTC.
The form to store, compare and transmit: there is no offset to disagree about.
Returns the same instant, at UTC
Examples
DateTime.parse("2026-07-30T14:03:00+02:00").try.utc.iso
# => "2026-07-30T12:03:00Z"
+
+(span: Duration) -> DateTime
Advances the instant by a fixed span, keeping its offset.
Parameters
span- the elapsed span to add
Returns the later instant
Examples
(DateTime.parse("2026-07-30T14:03:00Z").try + 90.minutes).iso
# => "2026-07-30T15:33:00Z"
-
-(span: Duration) -> DateTime
Moves the instant back by a fixed span, keeping its offset.
Parameters
span- the elapsed span to subtract
Returns the earlier instant
Examples
(DateTime.utcNow() - 24.hours).iso # yesterday, same wall clock
addDays
addDays(count: Integer) -> DateTime
The instant count days later, keeping the wall clock and the offset.
Parameters
count- how many days to add
Returns the shifted instant
Examples
DateTime.parse("2026-07-30T14:03:00Z").try.addDays(1).iso
# => "2026-07-31T14:03:00Z"
addWeeks
addWeeks(count: Integer) -> DateTime
The instant count weeks later, keeping the wall clock and the offset.
Parameters
count- how many weeks to add
Returns the shifted instant
Examples
DateTime.parse("2026-07-30T14:03:00Z").try.addWeeks(1).iso
# => "2026-08-06T14:03:00Z"
addMonths
addMonths(count: Integer) -> DateTime
The instant count calendar months later, with the day clamped into the target month.
Parameters
count- how many months to add
Returns the shifted instant
Examples
DateTime.parse("2026-01-31T09:00:00Z").try.addMonths(1).iso
# => "2026-02-28T09:00:00Z"
addYears
addYears(count: Integer) -> DateTime
The instant count calendar years later, with the day clamped.
Parameters
count- how many years to add
Returns the shifted instant
Examples
DateTime.parse("2024-02-29T09:00:00Z").try.addYears(1).iso
# => "2025-02-28T09:00:00Z"
until
until(other: DateTime) -> Duration
Elapsed time from this instant to other, negative when other is earlier. Sub-second precision is kept.
Parameters
other- the instant to measure to
Returns the elapsed span
Examples
started.until(DateTime.utcNow()).wholeSeconds
Enforcing a deadline
if started.until(DateTime.utcNow()).longerThan?(30.seconds)
IO.printError("timed out")
end
before?
before?(other: DateTime) -> Bool
Returns true when this instant is earlier than other, whatever offsets they are written at.
Parameters
other- the instant to compare against
Returns true when this instant comes first
Examples
DateTime.parse("2026-07-30T14:03:00+02:00").try
.before?(DateTime.parse("2026-07-31T00:00:00Z").try)
# => true
after?
after?(other: DateTime) -> Bool
Returns true when this instant is later than other.
Parameters
other- the instant to compare against
Returns true when this instant comes second
Examples
DateTime.utcNow().after?(started) # => true
iso
iso : String
The instant as ISO 8601 text, +2026-07-30T14:03:00+02:00+.
Returns the ISO 8601 instant
Examples
DateTime.parse("2026-07-30T14:03:00+02:00").try.iso
# => "2026-07-30T14:03:00+02:00"
compareTo
compareTo(other: DateTime) -> Ordering
Orders this instant against another, by instant rather than by wall clock, so 12:00Z and 14:00+02:00 compare Equal.
Named compareTo rather than compare: a make-block compare is shadowed by the builtin comparison dispatch and fails at runtime on both backends.
Parameters
other- the instant to compare against
Returns Less, Equal or Greater
Examples
DateTime.parse("2026-07-30T12:00:00Z").try
.compareTo(DateTime.parse("2026-07-30T14:00:00+02:00").try)
# => Equal
Sorting events by when they happened
events.sort { |a, b| a.at.compareTo(b.at) == Less }
From Showable
record Period
A calendar span. Months and years have no fixed length: February is 28 days or 29, a year 365 or 366, so they cannot live in a Duration, which is a count of seconds and nothing else. A Period carries the calendar fields themselves and lets the calendar resolve them:
Date.of(2026, 1, 31).try + 1.months # 2026-02-28, not 2026-03-03
Date.of(2024, 2, 29).try + 1.years # 2025-02-28
Use a Duration for elapsed time (36.hours is always 129600 seconds) and a Period for calendar steps (1.months is however long that month is).
Fields
Implements Inspectable, Showable.
Functions
inspectValue Inspectable
inspectValue(colors: Bool) -> String
Renders the period structurally, for debugging output.
Parameters
colors- whether to include ANSI color escapes
Returns the rendered period
showValue Showable
showValue : String
Renders the period as its ISO 8601 form, so interpolation and IO.printLine show P1Y2M3D.
Returns the ISO 8601 duration
Examples
"due in ${Period.of(0, 1, 0)}" # => "due in P1M"
+
+(other: Period) -> Period
Adds two calendar spans, field by field.
Parameters
other- the span to add
Returns the total
Examples
(Period.of(1, 2, 3) + 1.years).iso # => "P2Y2M3D"
-
-(other: Period) -> Period
Subtracts a calendar span, field by field. Fields may go negative.
Parameters
other- the span to subtract
Returns the difference
Examples
(Period.of(1, 2, 3) - 1.years).iso # => "P2M3D"
negated
negated : Period
The same span with every field's sign flipped: a step backwards.
Returns the negated span
Examples
Period.of(1, 2, 3).negated.iso # => "P-1Y-2M-3D"
*
*(factor: Integer) -> Period
Multiplies every field by factor.
Parameters
factor- the factor to multiply by
Returns the scaled span
Examples
(Period.of(1, 2, 3) * 2).iso # => "P2Y4M6D"
zero?
zero? : Bool
Returns true when every field is zero.
Returns true for a zero span
Examples
Period.zero().zero? # => true
Period.of(0, 1, 0).zero? # => false
totalMonths
totalMonths : Integer
The years and months of the span, as one count of months.
Years and months are one quantity for arithmetic: a month is 1/12 of a year exactly, unlike days, which are not a fixed fraction of a month. This is why date + span applies them together rather than one after the other.
Returns the total months, ignoring days
Examples
Period.of(1, 2, 3).totalMonths # => 14
normalized
normalized : Period
The same span with excess months carried into years.
14 months becomes 1 year 2 months. Days are left alone: there is no fixed number of them in a month to carry by.
Returns the normalised span
Examples
14.months.normalized.iso # => "P1Y2M"
From Showable
module Time
Building times of day, controlling the clock in tests, and the calendar arithmetic the rest of this file is written on.
of
of(hour: Integer, minute: Integer, second: Integer, nanosecond: Integer) -> Result<Time, TimeError>
Builds a validated time of day.
Every field is range-checked, so a Time you hold is always a real time. Leap seconds are not modeled, so a second of 60 is rejected.
Parameters
hour- the hour, 0 through 23
minute- the minute, 0 through 59
second- the second, 0 through 59
nanosecond- the sub-second part, 0 through 999999999; omitted for 0
Returns the time, or why it is not one
Examples
Time.of(14, 3, 0).map { |t| t.iso } # => Ok("14:03:00")
Time.of(25, 0, 0) # => Error(InvalidTime(25, 0, 0))
With sub-second precision
Time.of(14, 3, 0, 123456789).map { |t| t.iso }
# => Ok("14:03:00.123456789")
midnight
midnight : Time
Midnight, 00:00:00. The start of a day.
Returns 00:00:00
Examples
Time.midnight().iso # => "00:00:00"
(Time.midnight() - 1.seconds).iso # => "23:59:59"
fromSecondsSinceMidnight
fromSecondsSinceMidnight(count: Integer, nanosecond: Integer) -> Time
Builds a time of day from a count of seconds since midnight.
Wraps, so 86400 is midnight again and -1 is 23:59:59, which is what makes it total where Time.of is fallible.
Declared before the two-argument form: the interpreter resolves an overloaded module function to its LAST definition regardless of arity, so a delegating overload has to come first or it recurses into itself.
Parameters
count- seconds since midnight; wraps outside 0..86399
nanosecond- the sub-second part; omitted for 0
Returns the time of day
Examples
Time.fromSecondsSinceMidnight(50580).iso # => "14:03:00"
Time.fromSecondsSinceMidnight(86400).iso # => "00:00:00"
Time.fromSecondsSinceMidnight(-1).iso # => "23:59:59"
parse
parse(text: String) -> Result<Time, TimeError>
Parses an ISO 8601 time of day.
Accepts 14:03, 14:03:00, or 14:03:00.123456789. Anything else is InvalidFormat.
Parameters
text- the text to parse
Returns the time, or why it could not be read
Examples
Time.parse("14:03").map { |t| t.iso } # => Ok("14:03:00")
Time.parse("xx") # => Error(InvalidFormat("xx"))
now
now : Time
The current time of day, in this machine's zone.
Reads the same clock primitive everything else here does, so it is pinned by Time.frozenAt in a test.
Returns the current time of day
Examples
Time.now().iso # => "14:03:00"
utcNow
utcNow : Time
The current time of day in UTC, whatever this machine's zone is.
Returns the current UTC time of day
Examples
Time.utcNow().iso # => "12:03:00"
parseOffset
parseOffset(text: String) -> Result<Duration, TimeError>
Parses an ISO 8601 zone designator into an offset.
Accepts Z, +02:00, -05:30, or the empty string (all meaning UTC for the first and last).
Parameters
text- the zone designator
Returns the offset, or why it could not be read
Examples
Time.parseOffset("+02:00").map { |d| d.wholeSeconds } # => Ok(7200)
Time.parseOffset("Z").map { |d| d.wholeSeconds } # => Ok(0)
nanosOf
nanosOf(moment: DateTime) -> Integer
Anything that asks what time it is: Time.now, Date.today, DateTime.utcNow: reads one primitive, so pinning that primitive pins the whole calendar. This is what makes code that calls Date.today() testable: freeze the clock, assert against a date you chose.
Time.freeze(DateTime.parse("2026-07-30T14:03:00Z").try)
Date.today().iso # "2026-07-30": always
Time.release()
The clock is global, not per-process: a frozen clock stays frozen inside spawned processes, which is the only behavior that matches a real one. release is not automatic, so a test that freezes must also release: otherwise every later test in the run inherits the frozen clock.
Nanoseconds since the Unix epoch for a civil datetime. A plain function rather than a DateTime function: on BEAM a function named epochNanos flattens onto the same name as the DateTime.epochNanos() module function, and the arity-0 one wins: silently, answering for the host clock instead of for moment.
CLOCK_MIN_NANOS constant
The clock counts nanoseconds in a 64-bit integer, on both backends and in the host clock they stand in for. That is the whole of the instants it can name: 1677-09-21 to 2262-04-11. A Kex Integer keeps going past that: it promotes to arbitrary precision, so a date outside the range produces a number the clock cannot hold, and the check below is what stops it being truncated into some other instant entirely.
CLOCK_MAX_NANOS constant
freeze
freeze(moment: DateTime) -> Result<DateTime, TimeError>
Pins the clock: every reading returns this exact instant until release.
This is what makes code that calls Date.today() testable. Returns the moment it pinned, so Time.freeze(m).try both sets the clock and fails loudly on an instant the clock cannot represent.
Prefer Time.frozenAt, which releases for you: a test that fails between a freeze and its release leaves the clock frozen for everything after it.
Parameters
moment- the instant to pin the clock to
Returns the pinned moment, or why it could not be
Examples
Time.freeze(DateTime.parse("2026-07-30T14:03:00Z").try)
Date.utcToday().iso # => "2026-07-30", always
Time.release()
travel
travel(moment: DateTime) -> Result<DateTime, TimeError>
Moves the clock to an instant and lets it run from there.
Readings advance normally, they just start somewhere else. Use this over freeze when the code under test measures elapsed time: a frozen clock makes every interval zero.
Parameters
moment- the instant to start the clock from
Returns the moment set, or why it could not be
Examples
Time.travel(DateTime.parse("2026-07-30T14:03:00Z").try)
Time.release()
frozenAt
frozenAt(moment: DateTime, body: Block) -> Result<A, TimeError>
Freezes the clock for the length of body, then releases it.
This is the form to reach for: freeze and release have to be paired by hand, and a test that returns early (or fails an assertion) between them leaves the clock frozen for every test that runs after it.
Result carries whatever body returned. An instant the clock cannot represent is an Error, and then the clock is never touched and the body never runs.
Not nestable: release restores the HOST clock, not whatever control was in effect on entry, so an inner scope ending un-freezes the outer one too.
Parameters
moment- the instant to pin the clock to
body- what to run with the clock frozen
Returns whatever body returned, or why the clock could not be set Time.frozenAt(DateTime.parse("2026-07-30T14:03:00Z").try) do Date.utcToday().iso # "2026-07-30" end.try
travellingFrom
travellingFrom(moment: DateTime, body: Block) -> Result<A, TimeError>
Runs body with the clock started at moment, then releases it.
The same scoping as frozenAt, for travel: readings start at moment and advance normally, and the clock is released when body ends.
Parameters
moment- the instant to start the clock from
body- what to run with the clock moved
Returns whatever body returned, or why the clock could not be set
Examples
Measuring elapsed time from a known start
Time.travellingFrom(DateTime.parse("2026-07-30T14:03:00Z").try) do
runTheThing()
end.try
release
release : Void
Returns the clock to the host's.
Not automatic: a test that froze the clock must also release it, or every later test in the run inherits the frozen clock. frozenAt and travellingFrom do this for you.
Examples
after do
Time.release()
end
controlled?
controlled? : Bool
Returns true while freeze or travel is in effect.
Returns true when the clock is under test control
Examples
Time.controlled?() # => false on the host clock
frozen?
frozen? : Bool
Returns true while freeze is in effect: not merely travel.
Returns true when the clock is frozen
Examples
Time.frozen?() # => false on the host clock
leapYear?
leapYear?(year: Integer) -> Bool
Public because the make blocks below live at file level and reach them by qualification; they are equally useful on their own.
Returns true when year is a leap year in the proleptic Gregorian calendar.
Parameters
year- the calendar year
Returns true for a leap year
Examples
Time.leapYear?(2024) # => true
Time.leapYear?(1900) # => false
Time.leapYear?(2000) # => true
daysInMonth
daysInMonth(year: Integer, month: Integer) -> Result<Integer, TimeError>
The number of days in a month.
Year first, matching Date.of(year, month, day) and every other date-shaped signature in this file.
Returns Error(InvalidDate(year, month, 1)) when the month is outside 1 through 12.
Parameters
year- the calendar year, which decides February's length
month- the month, 1 through 12
Returns the day count, or why the month is not one
Examples
Time.daysInMonth(2024, 2) # => Ok(29)
Time.daysInMonth(2023, 2) # => Ok(28)
Time.daysInMonth(2026, 13) # => Error(InvalidDate(2026, 13, 1))
daysInValidMonth
daysInValidMonth(year: Integer, month: Integer) -> Integer
The number of days in a month, with the range check already done.
Every caller inside this file has a month it built or validated itself. Use daysInMonth for a month that came from outside.
Parameters
year- the calendar year
month- the month, assumed to be 1 through 12
Returns the day count
Examples
Time.daysInValidMonth(2024, 2) # => 29
daysFromCivil
daysFromCivil(year: Integer, month: Integer, day: Integer) -> Integer
The number of days from 1970-01-01 to a calendar date, negative before it.
Howard Hinnant's civil-calendar algorithms: exact across the whole proleptic Gregorian range, and they need only truncating integer division: the semantics Kex's / already has.
Parameters
year- the calendar year
month- the month, 1 through 12
day- the day of the month
Returns days since the Unix epoch
Examples
Time.daysFromCivil(1970, 1, 1) # => 0
Time.daysFromCivil(2026, 7, 30) # => 20664
civilFromDays
civilFromDays(epochDay: Integer) -> Date
The calendar date a count of days since 1970-01-01 lands on. The inverse of daysFromCivil.
Parameters
epochDay- days since the Unix epoch
Returns the calendar date
Examples
Time.civilFromDays(0).iso # => "1970-01-01"
Time.civilFromDays(20664).iso # => "2026-07-30"
weekdayFromEpochDay
weekdayFromEpochDay(epochDay: Integer) -> Weekday
The weekday a count of days since 1970-01-01 falls on.
Parameters
epochDay- days since the Unix epoch
Returns the day of the week
Examples
Time.weekdayFromEpochDay(0) # => Thursday (1970-01-01 was a Thursday)
weekdayNumber
weekdayNumber(weekday: Weekday) -> Integer
The ISO number of a weekday: Monday is 1, Sunday is 7.
weekday.number is the readable way to ask.
Parameters
weekday- the day of the week
Returns its ISO number, 1 through 7
Examples
Time.weekdayNumber(Monday) # => 1
weekdayName
weekdayName(weekday: Weekday) -> String
The English name of a weekday.
weekday.name is the readable way to ask.
Parameters
weekday- the day of the week
Returns its English name
Examples
Time.weekdayName(Sunday) # => "Sunday"
errorMessage
errorMessage(error: TimeError) -> String
Renders a TimeError as a sentence for the user.
A plain function rather than an Errorable implementation: a message function here joins the same BEAM dispatcher as ParseError's message FIELD and breaks it (spec/record_field_method_collision.kex).
Parameters
error- the failure to describe
Returns the message to show
Examples
Time.errorMessage(InvalidDate(2026, 13, 1))
# => "not a calendar date: 2026-13-1"
Reporting a rejected date
match Date.parse(input) do
Ok(date) => IO.printLine(date.iso)
Error(e) => IO.printError(Time.errorMessage(e))
end
formatDate
formatDate(value: Date) -> String
formatDateTime
formatDateTime(value: DateTime) -> String
formatTime
formatTime(value: Time) -> String
formatFraction
formatFraction(nanosecond: Integer) -> String
Fractional seconds, in the 3/6/9-digit groupings ISO 8601 output conventionally uses, whichever is the shortest that loses nothing. A whole second renders no fraction at all, so 14:03:00 is unchanged.
Without this the nanosecond field was kept on the value and compared, but never rendered: Time.parse("14:03:00.5") and Time.parse("14:03:00") produced different values that printed identically, and every parse/format round-trip silently dropped sub-second precision.
formatOffset
formatOffset(offset: Duration) -> String
±HH:MM, the shape an ISO 8601 offset takes. UTC renders as "Z".
withNanosecond
withNanosecond(moment: DateTime, nanosecond: Integer) -> DateTime
floorDiv
floorDiv(value: Integer, divisor: Integer) -> Integer
Kex's / truncates toward zero; instants before the epoch need the floor.
truncatedBy
truncatedBy(seconds: Float, unit: Float) -> Integer
padTo
padTo(value: Integer, width: Integer) -> String
Left-pad with zeros to a fixed width. A value already that wide is left alone rather than truncated: losing digits would be worse than a field one character too long.
padYear
padYear(value: Integer) -> String
Years keep four digits where they fit; ISO 8601 has no fixed spelling beyond that, so wider years render as-is.
digitsIn
digitsIn(fragment: String, whole: String) -> Result<Integer, TimeError>
Digits, with the failure reported against the WHOLE input rather than the fragment that failed: Time.parse("2:03 pm") should complain about "2:03 pm", not about "03 pm".
digitsToInteger
digitsToInteger(text: String) -> Result<Integer, TimeError>
parseFraction
parseFraction(text: String, whole: String) -> Result<Integer, TimeError>
".5" is 500000000ns: the digits are padded out to nanosecond scale.
splitOffset
splitOffset(text: String) -> [String]
Splits "14:03:00+02:00" into its time and offset halves. A missing offset reads as UTC, matching what a zero offset formats back to.
type Weekday
module Date
Building calendar dates, and asking what today is.
of
of(year: Integer, month: Integer, day: Integer) -> Result<Date, TimeError>
Builds a validated calendar date.
The month and the day are both range-checked, and the day is checked against that month's actual length, so February 30th is an Error, and a Date you hold is always a real day. The record literal Date { ... } bypasses this, so prefer it for anything derived from input.
Parameters
year- the calendar year
month- the month, 1 through 12
day- the day of the month
Returns the date, or why it is not one
Examples
Date.of(2026, 7, 30).map { |d| d.iso } # => Ok("2026-07-30")
Date.of(2026, 13, 1) # => Error(InvalidDate(2026, 13, 1))
Date.of(2023, 2, 29) # => Error(InvalidDate(2023, 2, 29))
Taking the date or failing loudly
let due = Date.of(year, month, day).try
fromEpochDay
fromEpochDay(day: Integer) -> Date
The calendar date a count of days since 1970-01-01 lands on, negative before it.
Parameters
day- days since the Unix epoch
Returns the calendar date
Examples
Date.fromEpochDay(0).iso # => "1970-01-01"
Date.fromEpochDay(20664).iso # => "2026-07-30"
parse
parse(text: String) -> Result<Date, TimeError>
Parses an ISO 8601 calendar date, 2026-07-30.
The result is validated as well as parsed, so a well-formed but impossible date is InvalidDate rather than InvalidFormat.
Parameters
text- the text to parse
Returns the date, or why it could not be read
Examples
Date.parse("2026-07-30").map { |d| d.iso } # => Ok("2026-07-30")
Date.parse("nope") # => Error(InvalidFormat("nope"))
Date.parse("2026-02-30") # => Error(InvalidDate(2026, 2, 30))
now
now : Date
Today's date, in this machine's zone.
Date.today() reads better in most code; now exists so every type in this file answers the same question the same way.
Returns today's date
Examples
Date.now().iso # => "2026-07-30"
today
today : Date
Today's date, in this machine's zone.
Pinned by Time.frozenAt in a test, like everything else that reads the clock.
Returns today's date
Examples
Date.today().iso # => "2026-07-30"
Testing code that depends on today
Time.frozenAt(DateTime.parse("2026-07-30T00:00:00Z").try) do
Assert.equal(Date.utcToday().iso, "2026-07-30")
end.try
tomorrow
tomorrow : Date
The day after today, in this machine's zone.
Returns tomorrow's date
Examples
Date.tomorrow().iso # => "2026-07-31"
yesterday
yesterday : Date
The day before today, in this machine's zone.
Returns yesterday's date
Examples
Date.yesterday().iso # => "2026-07-29"
module DateTime
Building instants, and asking what time it is now.
of
of(date: Date, time: Time, offset: Duration) -> DateTime
Combines a date, a time of day and a UTC offset into an instant.
Parameters
date- the calendar date
time- the time of day
offset- the offset from UTC
Returns the instant
Examples
DateTime.of(Date.of(2026, 7, 30).try,
Time.of(9, 0, 0).try,
Duration.zero()).iso
# => "2026-07-30T09:00:00Z"
At an offset
DateTime.of(date, time, Duration.utcOffset(2, 0))
fromEpochSeconds
fromEpochSeconds(count: Integer, offset: Duration) -> DateTime
The instant a count of seconds since the Unix epoch names, rendered at UTC or at the offset you give.
Declared before the two-argument form: the interpreter resolves an overloaded module function to its LAST definition regardless of arity, so a delegating overload has to come first or it recurses into itself.
Parameters
count- seconds since the Unix epoch
offset- the offset to render at; omitted for UTC
Returns the instant
Examples
DateTime.fromEpochSeconds(0).iso # => "1970-01-01T00:00:00Z"
Rendered at a local offset
DateTime.fromEpochSeconds(0, Duration.utcOffset(2, 0)).iso
# => "1970-01-01T02:00:00+02:00"
parse
parse(text: String) -> Result<DateTime, TimeError>
Parses an ISO 8601 instant.
Accepts +2026-07-30T14:03:00+02:00+, the same with Z, or a bare civil datetime with no zone at all, which is read as UTC.
Parameters
text- the text to parse
Returns the instant, or why it could not be read
Examples
DateTime.parse("2026-07-30T14:03:00+02:00").map { |m| m.utc.iso }
# => Ok("2026-07-30T12:03:00Z")
DateTime.parse("nope") # => Error(InvalidFormat("nope"))
Reading a timestamp out of a log line
DateTime.parse(line.take(20)).map { |m| m.epochSeconds }
now
now : DateTime
The current instant, in this machine's zone as it stands right now.
The offset is the one in effect at this instant, so it is right today. Named IANA zones are not modeled, so it cannot say what the offset WILL be for some future local time.
Returns the current instant
Examples
DateTime.now().iso # => "2026-07-30T14:03:00+02:00"
utcNow
utcNow : DateTime
The current instant, at UTC.
The form to prefer when the value is stored, compared or transmitted: there is no zone to disagree about.
Returns the current instant, at UTC
Examples
DateTime.utcNow().iso # => "2026-07-30T12:03:00Z"
epochNanos
epochNanos : Integer
Nanoseconds since the Unix epoch, straight from the clock.
The rawest reading available, and the right one for measuring a short interval: no calendar work happens on the way.
Returns nanoseconds since the Unix epoch
Examples
Timing a piece of work
let started = DateTime.epochNanos()
doTheThing()
let elapsedMs = (DateTime.epochNanos() - started) / 1000000
extends Integer
More functions for Integer, added by this module.
The plural spellings build a Duration; the singular ones from units.kex build a time Measure. 5.seconds is an elapsed span, 5.second a measurement.
nanoseconds
nanoseconds : Duration
microseconds
microseconds : Duration
milliseconds
milliseconds : Duration
This many milliseconds, as a Duration.
Returns the elapsed span
Examples
500.milliseconds.wholeMilliseconds # => 500
seconds
seconds : Duration
This many seconds, as a Duration.
Note the plural: 5.seconds is an elapsed span, while 5.second from units.kex is a measurement.
Returns the elapsed span
Examples
30.seconds.wholeSeconds # => 30
minutes
minutes : Duration
This many minutes, as a Duration.
Returns the elapsed span
Examples
90.minutes.wholeHours # => 1
hours
hours : Duration
This many hours, as a Duration.
Returns the elapsed span
Examples
36.hours.wholeDays # => 1
days
days : Duration
This many days, as a Duration: a fixed 86400 seconds each.
Use Period.days when the calendar should get a say.
Returns the elapsed span
Examples
(Date.of(2026, 7, 30).try + 10.days).iso # => "2026-08-09"
weeks
weeks : Duration
This many weeks, as a Duration: a fixed 604800 seconds each.
Returns the elapsed span
Examples
2.weeks.wholeDays # => 14
months
months : Period
This many calendar months, as a Period.
A calendar span, not a Duration: see the Period record above. 4.weeks and 1.months are deliberately different things: the first is exactly 28 days, the second is one calendar month, however long that turns out to be.
Returns the calendar span
Examples
(Date.of(2026, 7, 30).try + 1.months).iso # => "2026-08-30"
The calendar clamps a day that does not exist
(Date.of(2026, 1, 31).try + 1.months).iso # => "2026-02-28"
extends Float
More functions for Float, added by this module.
The same Duration constructors on Float, for fractional spans: 1.5.hours, 0.25.seconds.
nanoseconds
nanoseconds : Duration
microseconds
microseconds : Duration
milliseconds
milliseconds : Duration
This many milliseconds, as a Duration.
Returns the elapsed span
seconds
seconds : Duration
This many seconds, as a Duration.
Returns the elapsed span
Examples
0.25.seconds.wholeMilliseconds # => 250
hours
hours : Duration
This many hours, as a Duration.
Returns the elapsed span
Examples
1.5.hours.wholeMinutes # => 90
extends Duration
More functions for Duration, added by this module.
wholeNanoseconds
wholeNanoseconds : Integer
wholeMicroseconds
wholeMicroseconds : Integer
+
+(other: Duration) -> Duration
Adds two spans.
Parameters
other- the span to add
Returns the total
Examples
(90.minutes + 30.minutes).wholeHours # => 2
-
-(other: Duration) -> Duration
Subtracts a span. The result may be negative.
Parameters
other- the span to subtract
Returns the difference
Examples
(1.hours - 90.minutes).negative? # => true
negated
negated : Duration
The same span with its sign flipped.
Returns the negated span
Examples
90.minutes.negated.negative? # => true
*
*(factor: Number) -> Duration
Multiplies the span by a plain number.
3 * 1.days is not the same call because the receiver has to be the Duration, so it is spelled 1.days * 3.
Parameters
factor- the factor to multiply by
Returns the scaled span
Examples
(90.minutes * 2).wholeHours # => 3
/
/(divisor: Number) -> Duration
Divides the span by a plain number.
Parameters
divisor- the number to divide by
Returns the scaled span
Examples
(90.minutes / 2).wholeMinutes # => 45
abs
abs : Duration
The span's magnitude, discarding its direction.
Returns the absolute span
Examples
(1.hours - 90.minutes).abs.wholeMinutes # => 30
zero?
zero? : Bool
Returns true when the span is exactly zero.
Returns true for a zero span
Examples
Duration.zero().zero? # => true
negative?
negative? : Bool
Returns true when the span points backwards.
A negative span is what until gives you when the other moment is earlier, so this is how to ask which came first.
Returns true for a negative span
Examples
date.until(other).negative? # => true when `other` is earlier
positive?
positive? : Bool
Returns true when the span points forwards.
Returns true for a positive span
Examples
90.minutes.positive? # => true
shorterThan?
shorterThan?(other: Duration) -> Bool
Returns true when this span is shorter than other.
Named for length rather than for order: before?/after? are about when something happened, and a Duration is not a point in time.
Parameters
other- the span to compare against
Returns true when this span is shorter
Examples
30.minutes.shorterThan?(1.hours) # => true
Enforcing a timeout
if started.until(DateTime.utcNow()).longerThan?(30.seconds)
IO.printError("timed out")
end
longerThan?
longerThan?(other: Duration) -> Bool
Returns true when this span is longer than other.
Parameters
other- the span to compare against
Returns true when this span is longer
Examples
90.minutes.longerThan?(1.hours) # => true
wholeMilliseconds
wholeMilliseconds : Integer
The whole milliseconds in the span, truncated toward zero.
Returns the milliseconds
Examples
500.milliseconds.wholeMilliseconds # => 500
wholeSeconds
wholeSeconds : Integer
The whole seconds in the span, truncated toward zero.
Returns the seconds
Examples
Duration.hours(2).wholeSeconds # => 7200
wholeMinutes
wholeMinutes : Integer
The whole minutes in the span, truncated toward zero.
Returns the minutes
Examples
90.minutes.wholeMinutes # => 90
1.5.hours.wholeMinutes # => 90
wholeHours
wholeHours : Integer
The whole hours in the span, truncated toward zero. A partial hour does not count.
Returns the hours
Examples
90.minutes.wholeHours # => 1
wholeDays
wholeDays : Integer
The whole days in the span, truncated toward zero.
This is what +date + span+ uses, which is why +date + 36.hours+ advances exactly one day.
Returns the days
Examples
36.hours.wholeDays # => 1
2.weeks.wholeDays # => 14
wholeWeeks
wholeWeeks : Integer
The whole weeks in the span, truncated toward zero.
Returns the weeks
Examples
14.days.wholeWeeks # => 2
compareTo
compareTo(other: Duration) -> Ordering
Orders this span against another by length.
Delegates to Number.compare (algebra.kex), which orders the two Float second counts.
Parameters
other- the span to compare against
Returns Less, Equal or Greater
Examples
90.minutes.compareTo(60.minutes) # => Greater
Sorting by length
spans.sort { |a, b| a.compareTo(b) == Less }
module Duration
Building elapsed spans, and UTC offsets.
zero
zero : Duration
A span of no time at all. Also the UTC offset.
Returns the zero span
Examples
Duration.zero().zero? # => true
date.at(time, Duration.zero()).iso # => "...T09:00:00Z"
nanoseconds
nanoseconds(count: Number) -> Duration
microseconds
microseconds(count: Number) -> Duration
milliseconds
milliseconds(count: Number) -> Duration
A span of count milliseconds.
Parameters
count- how many milliseconds
Returns the elapsed span
Examples
Duration.milliseconds(1500).wholeSeconds # => 1
seconds
seconds(count: Number) -> Duration
A span of count seconds.
Parameters
count- how many seconds
Returns the elapsed span
Examples
Duration.seconds(90).wholeMinutes # => 1
minutes
minutes(count: Number) -> Duration
A span of count minutes.
Parameters
count- how many minutes
Returns the elapsed span
Examples
Duration.minutes(90).wholeHours # => 1
hours
hours(count: Number) -> Duration
A span of count hours.
Parameters
count- how many hours
Returns the elapsed span
Examples
Duration.hours(2).wholeSeconds # => 7200
days
days(count: Number) -> Duration
A span of count days, each a fixed 86400 seconds.
Parameters
count- how many days
Returns the elapsed span
Examples
Duration.days(2).wholeHours # => 48
weeks
weeks(count: Number) -> Duration
A span of count weeks, each a fixed 604800 seconds.
Parameters
count- how many weeks
Returns the elapsed span
Examples
Duration.weeks(2).wholeDays # => 14
utcOffset
utcOffset(hours: Integer, minutes: Integer) -> Duration
A whole-minute UTC offset, the only kind ISO 8601 can spell.
A negative hour or minute puts the whole offset west of UTC, so utcOffset(-5, 30) is five and a half hours behind UTC, not four and a half.
Parameters
hours- the hour part of the offset
minutes- the minute part of the offset
Returns the offset
Examples
Duration.utcOffset(2, 0).wholeSeconds # => 7200
Duration.utcOffset(-5, 0).wholeSeconds # => -18000
Rendering an instant at another offset
moment.at(Duration.utcOffset(-5, 0)).iso
module Period
Building calendar spans.
zero
zero : Period
A span of nothing.
Returns the zero span
Examples
Period.zero().iso # => "P0D"
Period.zero().zero? # => true
of
of(years: Integer, months: Integer, days: Integer) -> Period
A span of the given years, months and days together.
Parameters
years- whole years
months- whole months
days- whole days
Returns the calendar span
Examples
Period.of(1, 2, 3).iso # => "P1Y2M3D"
years
years(count: Integer) -> Period
A span of count calendar years.
Parameters
count- how many years
Returns the calendar span
Examples
(Date.of(2024, 2, 29).try + Period.years(1)).iso # => "2025-02-28"
months
months(count: Integer) -> Period
A span of count calendar months.
Parameters
count- how many months
Returns the calendar span
Examples
(Date.of(2026, 1, 31).try + Period.months(1)).iso # => "2026-02-28"