Import JSCalendar (RFC 8984)
data into %Tempo.IntervalSet{}.
This module wraps the jscalendar
parser and places its events on a timeline. Where that library
turns documents into structs, this one turns structs into time:
resolving a wall-clock start against its zone, deriving the end
from the duration, and expanding recurrence rules.
JSCalendar is the IETF's intended successor to iCalendar, so this
is the counterpart of Tempo.ICal — same destination, newer
format. Event metadata (uid, title, description, status,
…) rides on each interval's :metadata map, so downstream set
operations stay connected to their source.
The jscalendar dependency is declared optional: true in
mix.exs. This module is only compiled when it is available.
Start, duration, and the zone
An RFC 8984 event stores a wall-clock start and a separate
timeZone, not an instant. That is the whole point: an hour-long
meeting stays an hour long across a daylight-saving boundary,
where a stored end time would silently become two hours or none.
So the end here is always start + duration resolved in the
event's own zone.
An event with no timeZone is floating — the same wall clock
wherever it is read — and materialises as a zone-less %Tempo{}
rather than being anchored to the reader's zone.
Where a local time is ambiguous
RFC 8984 does not say which instant to pick when a wall-clock time falls in a daylight-saving fold or gap, so this module chooses and says so:
Ambiguous — the clock repeats an hour — takes the earlier instant, the first time that reading occurs.
A gap — the clock skips an hour, so the time never happens — takes the instant the gap ends, which is the first moment at or after the nominal time.
Both are the readings a person means by "half past two that morning". Neither is an error, because a calendar full of such events is ordinary and refusing to read them would be worse.
Overrides
RFC 8984 §4.3 builds a recurrence set in three steps: the rules
generate, the excluded rules remove, and recurrenceOverrides
adds, removes and varies. All three happen here, so a document
that cancels one week and moves another materialises what it
says rather than its unmodified series.
An override is keyed by recurrence id — the wall-clock moment
the rules produced — which is not necessarily where the
occurrence ends up, since a patch may move its start. Each key
is therefore resolved in the event's own zone and matched by
instant, the same way excludedRecurrenceRules is. A key that
matches nothing is an additional occurrence, iCalendar's RDATE
by another name, and an event may consist of nothing else: with
overrides and no rules it still recurs.
A patched occurrence carries its own metadata, so a renamed week arrives with the new title on its interval.
What is not expanded
localizations are parsed but not applied. A localisation is a
choice about which language to render, and nothing in an interval
set expresses that — the patches are on the object for a caller
who knows which locale they want.
Summary
Functions
Parse a JSCalendar document and return a %Tempo.IntervalSet{}.
Place an already-parsed JSCalendar object on a timeline.
Functions
@spec from_jscalendar( binary(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Parse a JSCalendar document and return a %Tempo.IntervalSet{}.
Every Event becomes one or more intervals — one per occurrence
when it recurs. A Group contributes its member events. A Task
contributes nothing: a task is work to be done, not time occupied,
and placing one on a timeline would say something the document
does not.
Arguments
jsonis a JSCalendar document as a string.
Options
:boundis a Tempo value within which recurring events are expanded. Required when any event has a recurrence rule with neithercountnoruntil; ignored when there are none.
Returns
{:ok, interval_set}; or{:error, reason}when the document cannot be parsed, a zone is unknown, or a recurrence needs a:boundthat was not supplied.
Examples
iex> json = ~s({
...> "@type": "Event",
...> "uid": "review",
...> "updated": "2026-06-01T09:00:00Z",
...> "title": "Quarterly review",
...> "start": "2026-06-02T09:00:00",
...> "duration": "PT1H"
...> })
iex> {:ok, set} = Tempo.JSCalendar.from_jscalendar(json)
iex> [interval] = Tempo.IntervalSet.to_list(set)
iex> Tempo.to_iso8601(interval)
"2026Y6M2DT9H0M0S/2026Y6M2DT10H0M0S"
iex> interval.metadata.title
"Quarterly review"
@spec to_interval_set(struct(), keyword()) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Place an already-parsed JSCalendar object on a timeline.
The struct counterpart of from_jscalendar/2, for when the
document has been decoded once already — as part of a JMAP
response, say.
Arguments
objectis aJSCalendar.Event.t/0,JSCalendar.Task.t/0orJSCalendar.Group.t/0.
Options
See from_jscalendar/2.
Returns
{:ok, interval_set}or{:error, reason}.
Examples
iex> event = %JSCalendar.Event{
...> uid: "e",
...> start: ~N[2026-06-02 09:00:00],
...> duration: %Duration{hour: 1}
...> }
iex> {:ok, set} = Tempo.JSCalendar.to_interval_set(event)
iex> Tempo.IntervalSet.count(set)
1