Recurrence

RFC 5545 RRULE recurrence via a pluggable adapter (rrule by default): a standalone editor, automatic expansion in every view, exceptions, and this / this-and-following / all edit semantics.

Recurrence editor

A standalone control that reads and writes an RRULE string through a two-way rule model. Edit the recurrence below and watch the serialized rule update live.

Loading editor…
<!-- Two-way bound to a signal holding the RRULE string. -->
<cal-recurrence-editor [(rule)]="rule" />

<p>Current rule: <code>{{ rule() }}</code></p>

Automatic expansion

An event carrying a recurrenceRule field is expanded by the views automatically across the visible range — there is no manual occurrence generation. Because the rule above is bound to the same signal, editing it re-expands this month live.

Loading month view…
import { computed, signal } from '@angular/core';
import { CalendarEvent } from '@ascentsparksoftware/angular-calendar';

rule = signal<string>('FREQ=WEEKLY;BYDAY=MO,WE,FR');

// One master event carries `recurrenceRule`. The view expands it across the
// visible range — no manual occurrence generation. Editing `rule` re-expands.
recurringEvents = computed<CalendarEvent[]>(() => [
  {
    id: 'r1',
    title: 'Standup',
    start: { epochMs: Date.parse('2026-06-01T13:00:00Z'), zone: 'America/New_York' },
    end: { epochMs: Date.parse('2026-06-01T13:30:00Z'), zone: 'America/New_York' },
    status: 'scheduled',
    recurrenceRule: rule(),
  },
]);

This / this-and-following / all

Editing one occurrence of a series has three scopes. THIS occurrence adds the occurrence date to recurrenceExceptions (via addRecurrenceException) and creates a standalone override linked back with recurrenceId. THIS AND FOLLOWING splits the series in two (splitSeriesAt terminates the master with an RRULE UNTIL and returns the rule + start for a new tail series). ALL simply edits the master's recurrenceRule so every occurrence follows. All three are pure functions over your event data.

import {
  addRecurrenceException,
  splitSeriesAt,
  type CalendarEvent,
  type ZonedDateTime,
} from '@ascentsparksoftware/angular-calendar';

// THIS occurrence: drop the occurrence from the series and create a standalone
// override that points back at the master via `recurrenceId`.
function editThis(series: CalendarEvent, occurrence: ZonedDateTime): CalendarEvent[] {
  const master = addRecurrenceException(series, occurrence);
  const override: CalendarEvent = {
    id: crypto.randomUUID(),
    title: series.title,
    start: occurrence,
    recurrenceId: series.id, // marks it as a detached instance of the series
  };
  return [master, override];
}

// THIS AND FOLLOWING: terminate the master at the occurrence (RRULE UNTIL) and
// start a fresh series from it. `ctx` supplies the recurrence + date adapters.
function editFollowing(series: CalendarEvent, occurrence: ZonedDateTime, ctx: SplitCtx) {
  const { head, tailRule, tailStart } = splitSeriesAt(series, occurrence, ctx);
  const tail: CalendarEvent = {
    id: crypto.randomUUID(),
    title: series.title,
    start: tailStart,
    recurrenceRule: tailRule,
  };
  return [head, tail]; // head keeps the past, tail carries the change forward
}

// ALL: just edit the master's recurrenceRule; every occurrence follows.
function editAll(series: CalendarEvent, nextRule: string): CalendarEvent {
  return { ...series, recurrenceRule: nextRule };
}

ICS export

The /export entry point serializes events to iCalendar text. A recurring event's RRULE is written straight onto its VEVENT, so importing calendars re-expand the series natively.

import { eventsToIcs } from '@ascentsparksoftware/angular-calendar/export';
import type { CalendarEvent } from '@ascentsparksoftware/angular-calendar';

const events: CalendarEvent[] = [
  {
    id: 'r1',
    title: 'Standup',
    start: { epochMs: Date.parse('2026-06-01T13:00:00Z'), zone: 'America/New_York' },
    end: { epochMs: Date.parse('2026-06-01T13:30:00Z'), zone: 'America/New_York' },
    recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
  },
];

// The RRULE is preserved on the VEVENT — calendars re-expand it on import.
const ics = eventsToIcs(events);
// BEGIN:VEVENT ... RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR ... END:VEVENT