# Recurrences and opening hours

A `RecurrenceRule` says when something repeats, such as a class every
Monday at 09:30. An `OpeningHours` says when a place is open, such as
`Mo-Fr 09:00-17:30`. Calendars and maps exchange and store each as one
text, so each is held as that text, and its parts (`BYDAY`, a rule's
spans) aren't fields code reads.

Each is a type built on `Text` that the standard library declares, as
the payment numbers are. A value is its standard text, its JSON a
string, and its storage one `text` column. Its validator checks
construction and literals, and text that breaks it breaks `format`. Your
app's own type can be built on one.

`==` compares the text, so two rules written differently are different
values even when they give the same occurrences. Neither type is
ordered, and `text.format` shows each as written, because CLDR has words
for neither.

## A recurrence's text

A recurrence is two lines joined by a line feed, the form `rrule.js` and
`python-dateutil` write.

The first line is the start. For a timed rule it is `DTSTART;TZID=`, an
IANA zone, `:` and the first occurrence's local date and time
(`20261026T093000`). For a rule on dates it is `DTSTART;VALUE=DATE:` and
a date, as RFC 5545 writes a date-only start (3.3.10) and an all-day
`VEVENT` writes its `DTSTART`. The type's `pattern` takes either.

The second line is `RRULE:` and the rule in RFC 5545's grammar, its
parts in any order, each once.

These aren't read: a start in UTC or with no zone (floating), a carriage
return, a folded line, `RDATE` and `EXDATE`. The rule itself has these
limits:

- `FREQ` is daily, weekly, monthly or yearly. `SECONDLY`, `MINUTELY` and
  `HOURLY` are refused, because a rule within a day is where calendars
  disagree most across a clock change.
- `UNTIL` is included. It is a UTC time with a zoned start, and a date
  with a date start, as RFC 5545 requires; the other kind is refused.
- `COUNT` and `UNTIL` aren't both given.
- A part RFC 5545's table rules out for the frequency is refused:
  `BYMONTHDAY` on a weekly rule, `BYWEEKNO` on any but a yearly one, and
  an ordinal on `BYDAY` on any but a monthly or yearly one.
- `BYSETPOS` with no other `BY` part is refused.
- `BYHOUR`, `BYMINUTE` and `BYSECOND` with a date start are refused, as
  RFC 5545 forbids them.
- **The start must be the rule's first occurrence.** RFC 5545 leaves any
  other start undefined, and calendars disagree about it, so refusing it
  keeps every reader of the text agreeing.

## How a rule expands

A rule expands as `python-dateutil` expands one, in local time. Each
period (a year, a month, a week from `WKST`, a day) gives the days its
`BY` parts pick, with RFC 5545's defaults from the start: a yearly rule
takes the start's month and day, a monthly one its day, a weekly one its
weekday, and every rule its clock time.

An ordinal on `BYDAY` counts within the month for a monthly rule or one
with `BYMONTH`, and within the year otherwise. `BYSETPOS` picks from the
period's set.

A day a month doesn't have (the 31st of a 30-day month, 29 February) is
never made, so it is skipped and not counted, as the RFC says: a yearly
rule from 29 February 2024 gives 2028, 2032 and so on.

## Recurrences and daylight saving

Daylight saving follows RFC 5545 (3.3.5 and 3.8.5.3): an occurrence is
its local time in the rule's zone. One the clocks skip is left out and
not counted, and one they show twice is the first of the two.

A start that falls in a gap takes the offset before the gap, an hour on
(`Dst.compatible`), and is still the first occurrence. A rule on dates
has no zone, and its occurrences are days.

## `temporal.occurrences`

`temporal.occurrences(rule, from:, count:)` gives the next `count`
occurrences at or after `from:`, or fewer when the rule ends first.
`COUNT` counts from the rule's start, not from `from:`.

The rule's form is in its text, which the checker can't see in a value,
so the result's type comes from `from:`:

- An `Instant` gives `List<ZonedTime>`, in the rule's zone.
- A `CalendarDate` gives `List<CalendarDate>`, the days of the next
  `count` occurrences on or after it. A rule on dates gives its own
  days. A timed rule gives the local date of each occurrence in its
  zone, one for each, from the first instant of `from` there.

A rule on dates asked for instants has no zone to place them in, so it
is a defect fault, never a guessed zone or an empty list. Code that may
hold either form asks for days. Text written out for `from:` is an
`Instant` or a date by how it is written.

`count` is proved from 1 to 1000 while compiling, as a count's sign is:
a literal within them, or a value whose type's `min` and `max` are.
Anything else is `type.mismatch`, as a `scale:` outside 0 to 18 is.
Without `COUNT`, the expansion starts near `from` rather than at the
rule's start.

## Opening hours' text

Opening hours are written in the part of OpenStreetMap's
`opening_hours` syntax that every reader agrees on, then a space and the
zone in brackets, as RFC 9557 writes one:
`Mo-Fr 09:00-17:30; Sa 10:00-16:00 [Europe/London]`.

- Rules are joined by `; `. Each is weekdays (`Mo-Fr`, `Sa,Su`, or
  `Fr-Mo` across the week's end) or one date (`2026 Dec 25`), a space,
  and `HH:MM-HH:MM` spans joined by commas, or `off`.
- An end is up to `24:00`. An end at or before its start runs past
  midnight.
- A later rule replaces an earlier one on the days both select, as in
  OpenStreetMap.
- A night past midnight belongs to the day it opens, so a day marked
  `off` doesn't cut short the night before.
- A day no rule selects is closed.

These aren't read: `closed`, `24/7`, public holidays (`PH`), months,
weeks, ranges of dates, sunrise and sunset, comments, and hours past
`24:00`.

## `temporal.open_at`

`temporal.open_at(hours, at)` reads the clock on the wall in the hours'
zone. A span holds the minute it opens, not the minute it closes. On
the night the clocks go back, a bar open to 02:00 is open both times
01:30 comes round, and on the night they go forward the skipped hour
never comes.

A class repeats every Monday at 09:30 in London, whatever the clocks do,
and a bar's Friday and Saturday nights run past midnight. The class
keeps its local time; a time the clocks skip is left out and not
counted, and of a time they show twice the first is taken. The bar's
hours are read by the clock on the wall:

```jadpo
type BarHours = OpeningHours {}

type CheckedAt = Instant {}

type ClassCount = Int {
    min: 1
    max: 10
}

type ClassSchedule = RecurrenceRule {}

type ClassTime = ZonedTime {}

type IsOpen = Bool {}

type SearchFrom = Instant {}

/// Whether a bar is open at `at`, by the clock in its zone.
function is_open(hours: BarHours, at: CheckedAt) -> IsOpen {
    return IsOpen(temporal.open_at(hours, at))
}

/// The next `count` classes on a timetable from `from`, each at its local
/// time in the timetable's zone.
function next_classes(
    timetable: ClassSchedule,
    from: SearchFrom,
    count: ClassCount
) -> List<ClassTime> {
    return list.map(temporal.occurrences(timetable, from: from, count: count), at -> ClassTime(at))
}
```

```jadpo
test "a class keeps its local time when the clocks change" {
    var monday_classes: List<ClassTime> = next_classes(
        "DTSTART;TZID=Europe/London:20261019T093000\nRRULE:FREQ=WEEKLY;BYDAY=MO",
        "2026-10-19T00:00:00Z",
        3
    )
    assert list.map(monday_classes, at -> temporal.instant(at)) == [
        Instant("2026-10-19T08:30:00Z"),
        Instant("2026-10-26T09:30:00Z"),
        Instant("2026-11-02T09:30:00Z")
    ]
    var gap_days: List<ClassTime> = next_classes(
        "DTSTART;TZID=America/New_York:20260307T023000\nRRULE:FREQ=DAILY;COUNT=3",
        "2026-03-01T00:00:00Z",
        10
    )
    assert list.map(gap_days, at -> temporal.instant(at)) == [
        Instant("2026-03-07T07:30:00Z"),
        Instant("2026-03-09T06:30:00Z"),
        Instant("2026-03-10T06:30:00Z")
    ]
    var fold_days: List<ClassTime> = next_classes(
        "DTSTART;TZID=America/New_York:20261101T013000\nRRULE:FREQ=DAILY",
        "2026-11-01T00:00:00Z",
        2
    )
    assert list.map(fold_days, at -> temporal.instant(at)) == [
        Instant("2026-11-01T05:30:00Z"),
        Instant("2026-11-02T06:30:00Z")
    ]
}

test "a night past midnight is open by the clock on the wall" {
    var pub_hours: BarHours = "Mo-Th 17:00-23:00; Fr,Sa 17:00-02:00; 2026 Dec 25 off [Europe/London]"
    assert is_open(pub_hours, "2026-10-25T00:30:00Z")
    assert is_open(pub_hours, "2026-10-25T01:30:00Z")
    assert not is_open(pub_hours, "2026-10-25T02:00:00Z")
    assert is_open(pub_hours, "2026-03-29T00:59:00Z")
    assert not is_open(pub_hours, "2026-03-29T01:00:00Z")
    assert not is_open(pub_hours, "2026-12-25T18:00:00Z")
    var club_hours: BarHours = "Mo-Th 17:00-23:00; Fr,Sa 17:00-02:00 [America/New_York]"
    assert is_open(club_hours, "2026-03-08T06:30:00Z")
    assert not is_open(club_hours, "2026-03-08T07:00:00Z")
    assert is_open(club_hours, "2026-11-01T06:30:00Z")
    assert not is_open(club_hours, "2026-11-01T07:00:00Z")
}
```
