Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Standard library

Standard library

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:00 for a LocalTime, never 09:30, 24:00:00 or a leap second.
  • 2026-10-25T09:30:00 for a LocalDateTime, with no offset.
  • 2026-10 for a YearMonth, and 2026, in four digits, for a Year.
  • 2026-W43 for an IsoWeek, with week 53 only in a year that has one.
  • +05:30 for a UtcOffset. UTC is +00:00, never Z or -00:00.
  • 2026-10-01/2026-10-31 for a DateRange.
  • 2026-Q4, upper case, for a Quarter, which has no ISO 8601 form.
  • october for a Month: its code is its own name, as Weekday'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:

  • Month is CLDR's stand-alone month name: "October".
  • YearMonth and Year use CLDR's pattern for the parts they have: "October 2026".
  • UtcOffset is CLDR's long localized GMT format: "GMT+05:30", "GMT+00:00".
  • CalendarPeriod is CLDR's long unit names joined as a list of units, as a Duration is: "1 year, 2 months, 10 days", "0 days", each negative part with its sign.
  • Quarter and IsoWeek are 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:

example
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))}
example
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.