# `Tempo.JSCalendar`
[🔗](https://github.com/elixir-tempo/tempo/blob/v1.6.4/lib/jscalendar.ex#L2)

Import JSCalendar ([RFC 8984](https://www.rfc-editor.org/rfc/rfc8984.html))
data into `%Tempo.IntervalSet{}`.

This module wraps the [`jscalendar`](https://github.com/elixir-tempo/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.

# `from_jscalendar`

```elixir
@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/T10H0M0S"
    iex> Tempo.Interval.metadata(interval).title
    "Quarterly review"

# `to_interval_set`

```elixir
@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

* `object` is a `t:JSCalendar.Event.t/0`,
  `t:JSCalendar.Task.t/0` or `t:JSCalendar.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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
