Tempo.JSCalendar (Tempo v1.3.0)

Copy Markdown View Source

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

from_jscalendar(json, options \\ [])

@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

  • json is a JSCalendar document as a string.

Options

  • :bound is a Tempo value within which recurring events are expanded. Required when any event has a recurrence rule with neither count nor until; 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 :bound that 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"

to_interval_set(object, options \\ [])

@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

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