# Time and calendar additions

These types add to the [time types](time-types.md):
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](zones-and-local-times.md)) | | 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](the-standard-operator-table.md)). 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](zones-and-local-times.md)).
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](arithmetic-and-comparison.md)).
`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](objects.md)): 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](standard-library.md#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](ranges.md)): 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:

```jadpo
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))
}
```

```jadpo
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"
}
```
