# Calendar events

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:

```jadpo
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 -> []
    }
}
```

```jadpo
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](zones-and-local-times.md)),
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:

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

```jadpo
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.
