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

Calendar events

On this page

A CalendarEvent is an entry in a calendar: a holiday, a booking, a shift. It is either all-day or timed.

An all-day event holds its first and last days, both included, and no zone. A holiday on 25 December is on 25 December wherever it is read, so it never moves a day for a reader in Kiritimati, fourteen hours ahead of UTC, or in Pago Pago, eleven hours behind.

A timed event holds its local start and end and the zone they are in, as a recurrence does, so a shift that starts at 22:00 keeps starting at 22:00 when the clocks change. temporal.resolve turns its start into an instant, with dst: choosing on the nights the clocks change.

An all-day event repeats on its days, with a rule that starts on a date and has no zone. A birthday on 29 February comes round only in leap years, as RFC 5545 skips a day a year doesn't have.

Its text is RFC 5545's VEVENT, where an all-day event's end is the day after its last, and its JSON is its parts:

example
type DiaryText = Text {}type DiaryJson = Text {}type DiaryUnreadable = Failure {    kind: FailureKind.invalid_value}type ReaderDay = CalendarDate {}type SeenAt = Instant {}type CoversReaderDay = Bool {}type SearchFrom = Instant {}type ShiftCount = Int {    min: 1    max: 10}/// An event read back from the text calendars exchange.function diary_entry_from_text(calendar_text: DiaryText) fails DiaryUnreadable -> CalendarEvent {    return attempt text.parse(calendar_text, as: CalendarEvent) {        malformed: DiaryUnreadable        invalid: DiaryUnreadable    }}/// An event read back from its JSON.function diary_entry_from_json(sent: DiaryJson) fails DiaryUnreadable -> CalendarEvent {    return attempt json.read(sent, as: CalendarEvent) {        malformed: DiaryUnreadable        mismatch: DiaryUnreadable    }}/// Whether an all-day event covers the day it is for someone in `zone` at/// `at`: its own days, whichever zone they are read in. A timed event has/// no days of its own.function covers_reader_day(event: CalendarEvent, at: SeenAt, zone: Zone) -> CoversReaderDay {    var reader_local_day: ReaderDay = ReaderDay(temporal.date(temporal.in_zone(at, zone)))    return match event.first_day {        some(first) -> match event.last_day {            some(last) -> CoversReaderDay(                ReaderDay(first) <= reader_local_day and reader_local_day <= ReaderDay(last)            )            none -> CoversReaderDay(false)        }        none -> CoversReaderDay(false)    }}/// When a timed event starts: its local start in its zone, the earlier of/// the two on the night the clocks go back and an hour on in the hour they/// skip; none for an all-day event.function event_start(event: CalendarEvent) -> SeenAt? {    return match event.start {        some(start) -> match event.zone {            some(zone) -> SeenAt(                temporal.instant(temporal.resolve(start, zone: zone, dst: Dst.compatible))            )            none -> none        }        none -> none    }}type StartUnclear = Failure {    kind: FailureKind.invalid_value}/// Where a local time lands in `zone` under each `dst:` choice: the earlier/// instant, the later, and the compatible one, earlier of two and later in/// a gap.function landings(at: LocalDateTime, zone: Zone) -> List<SeenAt> {    return [        SeenAt(temporal.instant(temporal.resolve(at, zone: zone, dst: Dst.earlier))),        SeenAt(temporal.instant(temporal.resolve(at, zone: zone, dst: Dst.later))),        SeenAt(temporal.instant(temporal.resolve(at, zone: zone, dst: Dst.compatible)))    ]}/// Where a local time lands in `zone` when it must be clear: one the/// clocks skip or show twice fails.function clear_landing(at: LocalDateTime, zone: Zone) fails StartUnclear -> SeenAt {    var landed: ZonedTime = attempt temporal.resolve(at, zone: zone) {        dst: StartUnclear    }    return SeenAt(temporal.instant(landed))}/// The days the next `count` of a repeating event fall on, from `from`; an/// all-day event's are its own days, whichever zone they are read in.function next_days(event: CalendarEvent, from: ReaderDay, count: ShiftCount) -> List<ReaderDay> {    return match event.recurrence {        some(rule) -> list.map(            temporal.occurrences(rule, from: from, count: count),            day -> ReaderDay(day)        )        none -> []    }}/// When the next `count` of a repeating event start, at its local time in/// its zone; none for one that doesn't repeat.function next_shift_starts(    event: CalendarEvent,    from: SearchFrom,    count: ShiftCount) -> List<SeenAt> {    return match event.recurrence {        some(rule) -> list.map(            temporal.occurrences(rule, from: from, count: count),            at -> SeenAt(temporal.instant(at))        )        none -> []    }}
example
test "an all-day event keeps its days, wherever and whenever it is read" {    var half_term: CalendarEvent = CalendarEvent {        summary: "Half-term walk"        first_day: "2026-10-24"        last_day: "2026-10-26"        start: none        end: none        zone: none        location: "Keswick, Cumbria"        description: none        recurrence: none    }    var half_term_vevent: DiaryText = DiaryText(text.from(half_term))    assert half_term_vevent == "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261024\nDTEND;VALUE=DATE:20261027\nSUMMARY:Half-term walk\nLOCATION:Keswick\\, Cumbria\nEND:VEVENT"    var half_term_from_text: CalendarEvent = attempt diary_entry_from_text(half_term_vevent)    assert half_term_from_text == half_term    var half_term_json: DiaryJson = DiaryJson(json.write(half_term))    assert half_term_json == "{\"summary\":\"Half-term walk\",\"first_day\":\"2026-10-24\",\"last_day\":\"2026-10-26\",\"start\":null,\"end\":null,\"zone\":null,\"location\":\"Keswick, Cumbria\",\"description\":null,\"recurrence\":null}"    var half_term_from_json: CalendarEvent = attempt diary_entry_from_json(half_term_json)    assert half_term_from_json == half_term    assert covers_reader_day(half_term, "2026-10-26T10:30:00Z", Zone.pacific_pago_pago)    assert not covers_reader_day(half_term, "2026-10-26T10:30:00Z", Zone.pacific_kiritimati)    assert covers_reader_day(half_term, "2026-10-23T10:30:00Z", Zone.pacific_kiritimati)    assert not covers_reader_day(half_term, "2026-10-23T10:30:00Z", Zone.pacific_pago_pago)    assert covers_reader_day(half_term, "2026-10-25T01:30:00Z", Zone.europe_london)}test "a timed event runs past midnight and repeats at its local time" {    var night_shift: CalendarEvent = CalendarEvent {        summary: "Night shift"        first_day: none        last_day: none        start: LocalDateTime {            date: "2026-10-24"            time: LocalTime {                hour: 22                minute: 0                second: 0            }        }        end: LocalDateTime {            date: "2026-10-25"            time: LocalTime {                hour: 2                minute: 0                second: 0            }        }        zone: Zone.europe_london        location: none        description: "Keys at the front desk; sign in, then out"        recurrence: "DTSTART;TZID=Europe/London:20261024T220000\nRRULE:FREQ=WEEKLY;COUNT=4"    }    var shift_vevent: DiaryText = DiaryText(text.from(night_shift))    assert shift_vevent == "BEGIN:VEVENT\nDTSTART;TZID=Europe/London:20261024T220000\nDTEND;TZID=Europe/London:20261025T020000\nSUMMARY:Night shift\nDESCRIPTION:Keys at the front desk\\; sign in\\, then out\nRRULE:FREQ=WEEKLY;COUNT=4\nEND:VEVENT"    var shift_from_text: CalendarEvent = attempt diary_entry_from_text(shift_vevent)    assert shift_from_text == night_shift    var shift_from_json: CalendarEvent = attempt diary_entry_from_json(        DiaryJson(json.write(night_shift))    )    assert shift_from_json == night_shift    assert next_shift_starts(night_shift, "2026-10-01T00:00:00Z", 3) == [        SeenAt("2026-10-24T21:00:00Z"),        SeenAt("2026-10-31T22:00:00Z"),        SeenAt("2026-11-07T22:00:00Z")    ]    assert diary_entry_from_text(        "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261024\nDTEND;VALUE=DATE:20261024\nSUMMARY:Half-term walk\nEND:VEVENT"    ) fails DiaryUnreadable    assert diary_entry_from_json(        "{\"summary\":\"Night shift\",\"first_day\":\"2026-10-24\",\"last_day\":\"2026-10-26\",\"start\":null,\"end\":null,\"zone\":\"Europe/London\",\"location\":null,\"description\":null,\"recurrence\":null}"    ) fails DiaryUnreadable    assert event_start(night_shift) == SeenAt("2026-10-24T21:00:00Z")}test "a start resolves across both clock changes as `dst:` chooses" {    // London's clocks go forward at 01:00 on 29 March 2026, so 01:30 never    // comes, and back at 02:00 on 25 October, so it comes twice.    var london_gap: LocalDateTime = LocalDateTime {        date: "2026-03-29"        time: LocalTime {            hour: 1            minute: 30            second: 0        }    }    var london_overlap: LocalDateTime = LocalDateTime {        date: "2026-10-25"        time: LocalTime {            hour: 1            minute: 30            second: 0        }    }    assert landings(london_gap, Zone.europe_london) == [        SeenAt("2026-03-29T00:30:00Z"),        SeenAt("2026-03-29T01:30:00Z"),        SeenAt("2026-03-29T01:30:00Z")    ]    assert landings(london_overlap, Zone.europe_london) == [        SeenAt("2026-10-25T00:30:00Z"),        SeenAt("2026-10-25T01:30:00Z"),        SeenAt("2026-10-25T00:30:00Z")    ]    assert clear_landing(london_gap, Zone.europe_london) fails StartUnclear    assert clear_landing(london_overlap, Zone.europe_london) fails StartUnclear    // New York's go forward at 02:00 on 8 March and back at 02:00 on 1    // November.    var new_york_gap: LocalDateTime = LocalDateTime {        date: "2026-03-08"        time: LocalTime {            hour: 2            minute: 30            second: 0        }    }    var new_york_overlap: LocalDateTime = LocalDateTime {        date: "2026-11-01"        time: LocalTime {            hour: 1            minute: 30            second: 0        }    }    assert landings(new_york_gap, Zone.america_new_york) == [        SeenAt("2026-03-08T06:30:00Z"),        SeenAt("2026-03-08T07:30:00Z"),        SeenAt("2026-03-08T07:30:00Z")    ]    assert landings(new_york_overlap, Zone.america_new_york) == [        SeenAt("2026-11-01T05:30:00Z"),        SeenAt("2026-11-01T06:30:00Z"),        SeenAt("2026-11-01T05:30:00Z")    ]    assert clear_landing(new_york_gap, Zone.america_new_york) fails StartUnclear    assert clear_landing(new_york_overlap, Zone.america_new_york) fails StartUnclear    var shift_start: LocalDateTime = LocalDateTime {        date: "2026-10-24"        time: LocalTime {            hour: 22            minute: 0            second: 0        }    }    var shift_landed: SeenAt = attempt clear_landing(shift_start, Zone.europe_london)    assert shift_landed == SeenAt("2026-10-24T21:00:00Z")}test "a birthday on 29 February comes round in leap years" {    var birthday: CalendarEvent = CalendarEvent {        summary: "Sam's birthday"        first_day: "2024-02-29"        last_day: "2024-02-29"        start: none        end: none        zone: none        location: none        description: none        recurrence: "DTSTART;VALUE=DATE:20240229\nRRULE:FREQ=YEARLY"    }    assert next_days(birthday, "2024-03-01", 2) == [        ReaderDay("2028-02-29"),        ReaderDay("2032-02-29")    ]    assert event_start(birthday) == none    var birthday_vevent: DiaryText = DiaryText(text.from(birthday))    assert birthday_vevent == "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20240229\nDTEND;VALUE=DATE:20240301\nSUMMARY:Sam's birthday\nRRULE:FREQ=YEARLY\nEND:VEVENT"    var birthday_from_text: CalendarEvent = attempt diary_entry_from_text(birthday_vevent)    assert birthday_from_text == birthday    var birthday_from_json: CalendarEvent = attempt diary_entry_from_json(        DiaryJson(json.write(birthday))    )    assert birthday_from_json == birthday}

An event's parts

A CalendarEvent has these parts, in order:

  • summary, one line of text.
  • For an all-day event, first_day and last_day, each a CalendarDate. The last day is included, as a DateRange's end is.
  • For a timed event, start and end, each a LocalDateTime, and its zone.
  • location, one line of text.
  • description, text over several lines. It holds no control character but a tab or a line feed, because RFC 5545 text can't hold one.
  • recurrence, a RecurrenceRule.

Every part but summary may be none. Its rules across parts say which go together:

  • days_or_times: an all-day event has both days and none of start, end and zone; a timed one has all three and neither day. A timed event's start and end are in its one zone: a flight between zones is two local times in one of them. A floating time (no zone) and a time in UTC aren't read.
  • last_day_not_before_first: an all-day event's last day is on or after its first.
  • end_after_start: a timed event's end is later than its start, as RFC 5545 requires.
  • recurrence_from_start: an all-day event's recurrence is a rule on dates whose start is its first_day, and a timed event's is a zoned rule from its start in its zone, because a VEVENT writes one DTSTART for both.

A literal that breaks one of these rules is type.invalid_literal. In a literal, a part written none is known to be absent, and one written out (text, a variant, an object) is known to be given. A part built from a value is proved or attempted by the rule, and text or JSON that breaks a rule breaks it by name.

No UID or DTSTAMP

RFC 5545 requires a UID and a DTSTAMP of a VEVENT in a calendar file, but they belong to that file's copy of the event, not to the event: an identity, and the time a calendar store last revised it. A CalendarEvent is the event itself, so two with the same parts are equal. Your app keeps its own identity and times beside one, such as an entity's id, and the writer of calendar files writes them from those. A text with either is malformed.

An event as text

An event's text is the VEVENT's lines joined by line feeds, as a RecurrenceRule's are, in one order: BEGIN:VEVENT, DTSTART, DTEND, SUMMARY, then LOCATION, DESCRIPTION and RRULE where it has them, and END:VEVENT.

  • An all-day event's start and end are dates, DTSTART;VALUE=DATE:, and its DTEND is the day after its last, as RFC 5545 writes it.
  • A timed event's are local times with the zone, DTSTART;TZID=Europe/London:, as a recurrence's start is.
  • Its RRULE is the recurrence's rule line. It is read with the event's DTSTART line as the rule's start, and written back the same way.
  • Text values escape \, ;, , and a line feed as RFC 5545 3.3.11 does (\n, never \N).

Only that form is read: no carriage return, folded line, other property or parameter, or DURATION for an end. RFC 5545 asks a calendar file for carriage returns and folded lines, which the writer of calendar files adds. An event whose last day is 31 December 9999 has no day after it to write, so it is the fault time_out_of_range.

An event in JSON

An event's JSON is its parts, each in its own canonical text and null where it is none: {"summary": "Half-term walk", "first_day": "2026-10-24", "last_day": "2026-10-26", "start": null, ...}. A local time is "2026-10-24T22:00:00", the zone "Europe/London", and the recurrence its two lines.

It is shown to people as its canonical text, as a recurrence is, because CLDR has no pattern for an event. Your app shows the parts it wants, each by its own format, such as event.first_day by a date format.

It isn't ordered, and == compares it part by part. It has no shape: type.standard_exists matches no app's event type, since { title, start, end } is too common to be distinctive. It isn't personal data of itself.

Local times and temporal.resolve

temporal.resolve places a local date and time in a zone. Its first argument is a date, given its clock time with at:, or a LocalDateTime, which carries its own and takes no at:. That first parameter is still named date. at: is a clock time written out, or a LocalTime.

Either way the local time is placed in zone: under dst: (Zones and local times), unchanged:

  • In a gap, Dst.earlier moves it back by the gap, and Dst.later and Dst.compatible move it forward.
  • In an overlap, Dst.earlier and Dst.compatible take the first instant, and Dst.later the second.
  • Left out, or Dst.reject, the call fails and its attempt maps dst:.

A date with no at: is type.argument_count, and at: beside a LocalDateTime is type.temporal_option_unused. A value that is neither a LocalTime nor a clock time written out is type.temporal_clock_invalid. An editor's snippet for the call leaves at: out, since it is an option.

Storing an event

An event is stored a column per part, and each part that may be none is a column that may be NULL. The days are in date columns, which no reader's zone moves; a local time in a timestamp without a zone; the zone as its IANA name; and the recurrence as its text. So a stored event comes back as it was written:

example
/// A slot in someone's diary: a day off or a shift.entity Booking {    id: Uuid    slot: CalendarEvent  // a column per part, its days `date`s}
example
use diary/entities { Booking }test "an event is kept part by part" {    var half_term: CalendarEvent = CalendarEvent {        summary: "Half-term walk"        first_day: "2026-10-24"        last_day: "2026-10-26"        start: none        end: none        zone: none        location: "Keswick, Cumbria"        description: none        recurrence: none    }    var half_term_booked: Booking = book(half_term)    var half_term_kept: Booking = attempt Booking.get(half_term_booked.id)    assert half_term_kept.slot == half_term    assert text.from(half_term_kept.slot) == text.from(half_term)    var night_shift: CalendarEvent = CalendarEvent {        summary: "Night shift"        first_day: none        last_day: none        start: LocalDateTime {            date: "2026-10-24"            time: LocalTime {                hour: 22                minute: 0                second: 0            }        }        end: LocalDateTime {            date: "2026-10-25"            time: LocalTime {                hour: 2                minute: 0                second: 0            }        }        zone: Zone.europe_london        location: none        description: "Keys at the front desk"        recurrence: "DTSTART;TZID=Europe/London:20261024T220000\nRRULE:FREQ=WEEKLY;COUNT=4"    }    var shift_booked: Booking = book(night_shift)    var shift_kept: Booking = attempt Booking.get(shift_booked.id)    assert shift_kept.slot == night_shift}

Not built yet: a writer of calendar files, which adds the carriage returns and line folding RFC 5545 asks of a file and writes UID and DTSTAMP from your app's own identity and times.

Markdown for agents: /docs/calendar-events.md.Built from Jadpo main at a707efb.