Time and calendar additions
On this page
These types add to the time types:
a time of day, a month, a week, a range of dates, an amount of calendar
time, and the text forms of recurrences, opening hours and calendar
events. The 21 temporal operations of the time types still apply;
three of them have SQL forms, so an aggregate query can group by time
buckets. The types here add three operations:
| Operation | Gives | Fails | SQL form |
|---|---|---|---|
temporal.year(value), temporal.month(value), temporal.day(value) |
Int, of a date or a local time's date (Zones and local times) |
of a date: extract, as bigint, with PostgreSQL's 1 BC counted as the year 0; none of a local time, whose zone PostgreSQL's time zone database may not see as Jadpo's does |
|
temporal.occurrences(rule, from:, count:) |
List<ZonedTime>, the next count occurrences of a RecurrenceRule (RFC 5545) at or after from, an Instant, in the rule's zone; from a CalendarDate, List<CalendarDate>, the days they fall on, which a rule on dates gives; count 1 to 1000 |
none | |
temporal.open_at(hours, at) |
Bool: an OpeningHours is open at the Instant, in its zone, across midnight and its exceptions |
none | |
temporal.period_between(start, end) |
CalendarPeriod in whole years, months and days from one CalendarDate to another, so adding it to start gives end; negative parts when end is earlier |
the months between the two months, one fewer or more by the day of the month, stepped once toward zero where adding them clamps past the end, and the days left; not age, which borrows days from the start's month |
CalendarPeriod
A CalendarPeriod is an amount of calendar time: whole years, months and
days, written P1Y2M10D in ISO 8601. It is neither a Duration, which
is exact time, nor a DateRange, which is a span between two dates. It
has no order, because one month and 30 days don't compare, and ==
compares it part by part.
It is stored as its three parts in integer columns, not as an
interval. PostgreSQL keeps an interval as months and days, so P12M
would come back as P1Y.
CalendarDate + CalendarPeriod applies years, then months, then days
(The standard operator table). A day
past the month's end becomes that month's last day, so 31 January plus
one month is 28 or 29 February. That is temporal.add_months with
InvalidDay.last_valid_day
(Zones and local times).
An app that must refuse such a date calls add_months with
InvalidDay.reject instead.
A ZonedTime takes no CalendarPeriod by operator, because it would
also need a dst: choice. Use temporal.add_months and its siblings.
Moving a date by a period
Moving a date by a period keeps the date's own named type, as
Instant + Duration does
(Arithmetic and comparison).
date - period adds the period with every part negated, so 31 March
less a month is 28 February.
The date comes first. Int * period, a Decimal factor, a ZonedTime
and < on periods are refused, as type.arithmetic_operand or
type.ordering_operand. Moving past the years a date can hold is the
fault time_out_of_range, and a part past 64 bits is an overflow, which
is a defect.
The period between two dates
temporal.period_between(start, end) counts a month once the end's day
of the month reaches the start's, as PostgreSQL's age and java.time
do: 30 January to 28 February is P29D. It takes one month fewer where
clamping to a month's last day would pass the end, and gives the rest as
days. So start + period is end for every pair, and every part has
one sign.
Going backwards it counts from the end, so it isn't always the forward
period negated: 25 October to 29 March is P-6M-27D, and forward it is
P6M26D.
Its SQL form doesn't use age, which borrows days from the start's
month and adds back past the end: 29 January to 1 March 2028 is age's
1 month 3 days, and here P1M1D.
The calendar types
| Type | Fields | Canonical text and JSON | Shown in en_gb |
|---|---|---|---|
LocalTime |
hour (0 to 23), minute, second (0 to 59) |
09:30:00 |
by format:, a time format: "09:30" |
LocalDateTime |
date: CalendarDate, time: LocalTime |
2026-10-25T09:30:00 |
by format:, with no zone: "25 Oct 2026, 09:30:00" |
Month (enum) |
january to december |
october |
"October" |
YearMonth |
year (0 to 9999), month (1 to 12) |
2026-10 |
"October 2026" |
Year |
year (0 to 9999) |
2026 |
"2026" |
Quarter |
year, quarter (1 to 4) |
2026-Q4 |
its canonical text |
IsoWeek |
year, week (1 to 52, or 53) |
2026-W43 |
its canonical text |
UtcOffset |
minutes (-1080 to 1080) |
+05:30 |
"GMT+05:30" |
DateRange |
start, end, both CalendarDate, the end included |
2026-10-01/2026-10-31; JSON { "start", "end" } |
each end by format:, a date format |
CalendarPeriod |
years, months, days |
P1Y2M10D |
"1 year, 2 months, 10 days" |
RecurrenceRule |
held as its text (RFC 5545): the first occurrence as a local time in its zone, or a date alone, a line feed, the rule | DTSTART;TZID=Europe/London:20261026T093000 and RRULE:FREQ=WEEKLY;BYDAY=MO, on two lines; on dates, DTSTART;VALUE=DATE:20240229 and RRULE:FREQ=YEARLY |
as written |
OpeningHours |
held as its text (OpenStreetMap's opening_hours) and its zone |
Mo-Fr 09:00-17:30; Sa 10:00-16:00 [Europe/London] |
as written |
CalendarEvent |
summary; an all-day event's first_day and last_day, both included, or a timed one's start and end (LocalDateTime) and zone; location, description and recurrence (RecurrenceRule, on dates for an all-day event), each may be none |
an RFC 5545 VEVENT, an all-day DTEND the day after the last; JSON its parts |
as written |
Calendar types are records of their parts
The types in the table from LocalTime to CalendarPeriod, except the
enum Month, are records of their parts, as InstantRange is, so code
reads at.hour and week.year. Each field is either a built-in whole
number with a range, or another standard type (LocalDateTime's
time). The ranges are LocalTime's hour from 0 to 23, a year from 0
to 9999 as a CalendarDate's is, Quarter's quarter from 1 to 4, and
UtcOffset's minutes within 18 hours.
An object literal proves each range as it proves any object's field
rules (Objects): a literal out of range
is type.invalid_literal, and a value not proved in range is
type.field_unproved. LocalTime.hour(raw) builds a field's value with
its min and max. An IsoWeek's week 53 in a year with 52 weeks
breaks its rule across fields, week_in_year, in text too. Year is a
record of one field, year.
Calendar types as text
Canonical text is ISO 8601's, to the second and with nothing optional, and only the form written is read:
09:30:00for aLocalTime, never09:30,24:00:00or a leap second.2026-10-25T09:30:00for aLocalDateTime, with no offset.2026-10for aYearMonth, and2026, in four digits, for aYear.2026-W43for anIsoWeek, with week 53 only in a year that has one.+05:30for aUtcOffset. UTC is+00:00, neverZor-00:00.2026-10-01/2026-10-31for aDateRange.2026-Q4, upper case, for aQuarter, which has no ISO 8601 form.octoberfor aMonth: its code is its own name, asWeekday's is.
A CalendarPeriod writes each part that isn't zero, in the order years,
months, days, with its own sign when negative, as PostgreSQL writes an
interval (P1Y-2M3D). A period of nothing is P0D. Weeks (P1W), a
+ and a written zero part are refused, so a period has one text.
Calendar types in JSON and storage
Each type's JSON is its canonical text, as a Duration's is, except a
DateRange, which is an object of its two dates, as an InstantRange
is.
Each is stored natively where PostgreSQL has the type: a LocalTime in
time, a LocalDateTime in timestamp without a zone, a Year and a
UtcOffset (its minutes) in a smallint, and a DateRange in
daterange. A YearMonth, a Quarter and an IsoWeek are stored in a
smallint per part, a CalendarPeriod in its three integer columns,
and a Month by its code.
Which calendar types are ordered
LocalTime, LocalDateTime, YearMonth, Year, Quarter and
IsoWeek are ordered, part by part in declared order, which is the
canonical order. < and sort_by take them. UtcOffset, Month,
DateRange and CalendarPeriod aren't ordered; Month is an enum, and
Weekday isn't ordered either.
Showing calendar types to people
LocalTime and LocalDateTime take format:, as the time types do,
and only a format of the parts they have. A date in a LocalTime's
format, or a zone in either's, is
type.temporal_format_part_missing (Errors), and zone: is
type.temporal_option_unused. A DateRange shows each date by a date
format, and a clock time in it is type.temporal_format_requires_time.
The others use CLDR's patterns:
Monthis CLDR's stand-alone month name: "October".YearMonthandYearuse CLDR's pattern for the parts they have: "October 2026".UtcOffsetis CLDR's long localized GMT format: "GMT+05:30", "GMT+00:00".CalendarPeriodis CLDR's long unit names joined as a list of units, as aDurationis: "1 year, 2 months, 10 days", "0 days", each negative part with its sign.QuarterandIsoWeekare shown as their canonical text, because ICU4X has no quarter or week-of-year patterns yet.
DateRange
A DateRange includes its end in the range operations
(Ranges): a range whose start is its end holds that day, and
two ranges that meet on a day overlap on it. Its fields take values
built on CalendarDate as an InstantRange's take instants, so a named
date type is a mismatch there.
Its shape, the fields that mark your own type as a copy of it for
type.standard_exists, is a first day (start, starts_on or
start_date) and a last day (end, ends_on or end_date), each
built on CalendarDate.
A rental runs for a calendar period from its first day. How long someone has lived somewhere is a period between two days, whatever the clocks did in between:
type RentalDay = CalendarDate {}type SeenAt = Instant {}type Rental = Object { first_day: RentalDay rental_term: CalendarPeriod}/// The day a rental ends: its rental_term on from the day it first_day, a day past/// a month's end becoming that month's last day.function rental_end(rental: Rental) -> RentalDay { return rental.first_day + rental.rental_term}/// The day it is in `zone` at `at`.function local_day(at: SeenAt, zone: Zone) -> RentalDay { return RentalDay(temporal.date(temporal.in_zone(at, zone)))}/// How long someone has lived somewhere, in whole years, months and/// days, from the day they moved in to `checked_on`.function time_lived(moved_in: RentalDay, checked_on: RentalDay) -> CalendarPeriod { return temporal.period_between(moved_in, checked_on)}type InRental = Bool {}/// Whether `day` is one of the days a rental in_rental, its last included.function in_rental(days: DateRange, day: RentalDay) -> InRental { return InRental(range.contains(days, day))}
test "a period counts calendar days, whatever the clocks do" { var london_spring: RentalDay = local_day("2026-03-29T00:30:00Z", Zone.europe_london) var london_autumn: RentalDay = local_day("2026-10-25T23:30:00Z", Zone.europe_london) assert london_spring == RentalDay("2026-03-29") assert london_autumn == RentalDay("2026-10-25") assert time_lived(london_spring, london_autumn) == CalendarPeriod { years: 0 months: 6 days: 26 } assert rental_end( Rental { first_day: london_spring rental_term: time_lived(london_spring, london_autumn) } ) == london_autumn var new_york_spring: RentalDay = local_day("2026-03-08T07:30:00Z", Zone.america_new_york) var new_york_autumn: RentalDay = local_day("2026-11-02T03:30:00Z", Zone.america_new_york) assert new_york_autumn == RentalDay("2026-11-01") assert text.from(time_lived(new_york_spring, new_york_autumn)) == "P7M24D" assert text.from(time_lived(new_york_autumn, new_york_spring)) == "P-7M-24D"}test "a month on from the 31st is the month's last day" { var rental: Rental = Rental { first_day: "2026-01-31" rental_term: CalendarPeriod { years: 0 months: 1 days: 0 } } assert rental_end(rental) == "2026-02-28" assert rental.rental_term * 12 + rental.rental_term == CalendarPeriod { years: 0 months: 13 days: 0 } var days: DateRange = DateRange { start: "2026-01-31" end: "2026-02-28" } assert in_rental(days, "2026-02-28") assert not in_rental(days, "2026-03-01") assert text.format(rental.rental_term * 3, locale: Locale.en_gb) == "3 months"}
Markdown for agents: /docs/time-and-calendar-additions.md.Built from Jadpo main at a707efb.