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

Recurrences and opening hours

On this page

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:

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

Markdown for agents: /docs/recurrences-and-opening-hours.md.Built from Jadpo main at a707efb.