Skip to main content

Events

Kiln has no Event resource, and that is the design.

An event is a content type that carries a datetime_range field. You compose one at /editor/types and /editor/fields; everything downstream — occurrence expansion, .ics output, schema.org/Event structured data — keys on the presence of that field rather than on a hardcoded type name. A venue's "Gig", a studio's "Workshop" and a school's "Open Day" are three types with three field sets and one calendar mechanism.

Composing an event type

{:ok, td} = CMS.create_type_definition(%{
  name: "gig", label: "Gig", plural_label: "gigs", path_segment: "gigs",
  # Optional. Gets you a schema.org Event node on the fired :json_ld surface.
  schema_org_type: "Event"
}, actor: admin)

CMS.create_field_definition!(%{
  type_definition_id: td.id, name: "when", label: "When",
  field_type: :datetime_range
}, actor: admin)

# Optional — only for types whose events repeat.
CMS.create_field_definition!(%{
  type_definition_id: td.id, name: "repeats", label: "Repeats",
  field_type: :recurrence
}, actor: admin)

A type with a datetime_range field is event-shaped. The first such field is the schedule if a type has several — one calendar entry per document, not per field.

datetime_range: local wall time plus a zone

%{
  "start" => "2026-03-15T19:00:00",
  "end" => "2026-03-15T21:00:00",
  "time_zone" => "Europe/London",
  "all_day" => false
}

start and end are local wall time, offset-less, paired with an IANA zone name. This is deliberately not how the rest of Kiln stores time — published_at and friends are UTC instants, because for an editorial timestamp the moment is the fact.

An event is the opposite. "The doors open at 19:00" is a fact about the local clock and the UTC instant is derived from it. Store UTC and the event silently moves the next time a government changes its DST rules: 18:00Z becomes a 20:00 concert, while 19:00 Europe/London stays a 19:00 concert.

The zone is part of the value, not a display preference. A value naming no zone falls back to the deployment default:

config :kiln_cms, KilnCMS.Events, time_zone: "Europe/London"

which itself defaults to Etc/UTC — honest rather than helpful. An operator running a venue should set it.

all_day: true means the times are ignored and the range covers whole days.

recurrence: an RRULE subset

%{"rrule" => "FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231T000000Z",
  "exdates" => ["2026-12-25"]}

Supported: FREQ (DAILY WEEKLY MONTHLY YEARLY), INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, WKST. Anything else — BYSETPOS, BYWEEKNO, BYYEARDAY, BYHOUR, BYMINUTE, BYSECOND — is rejected at the form, never silently ignored: an editor who writes a rule Kiln cannot honour should find out then, not from a subscriber asking why the calendar is wrong.

Expansion is wall-clock, so "every Tuesday at 19:00" stays at 19:00 local across a DST boundary rather than drifting to 18:00 or 20:00. Where a local time does not exist (the spring-forward gap) the occurrence moves forward past the gap; where it happens twice (autumn), the first is used.

Expansion is always windowed and always capped — there is no "all occurrences of this document" call, because FREQ=DAILY with no UNTIL has infinitely many.

The duration recurs, not the end instant: an event that runs 19:00–21:00 runs two hours on every occurrence, including one on the far side of a DST change where the two UTC offsets differ.

Calendars

PathWhat it carries
/calendar.icsevery event-shaped type
/<plural>/calendar.icsone type
/<plural>/tags/<tag>/calendar.icsone type, one taxonomy term
/<plural>/<slug>/calendar.icsone document (the "add to calendar" download)

Published and audience: :public only. A subscribed calendar is fetched by an anonymous client on a timer, forever, so gated content is filtered out explicitly rather than left to a read policy staying shaped as it is today.

A recurring event ships as an RRULE, not as expanded VEVENTs. A calendar client understands rules, so this is both smaller and more correct: the client keeps showing occurrences past whatever window Kiln happened to expand, and a subscriber who syncs once a year does not lose the tail. Skipped dates ride as EXDATE, so a cancelled occurrence stays cancelled on the client too.

Responses are cached for five minutes, carry a matching Cache-Control, and are dropped by the same publish hooks that drop the Atom feeds. Delivery pages advertise them with <link rel="alternate" type="text/calendar">, so a client finds the calendar without being handed the URL.

A recurring event's UNTIL and EXDATE are rendered in DTSTART's own value type — DATE for an all-day event, DATE-TIME at the event's local time otherwise. A date-only EXDATE on a 19:00 event matches no occurrence, which is the classic way a cancelled date comes back.

What's on: the occurrence-sorted index

PathWhat it carries
/<plural>one type's upcoming events as HTML, soonest first
/<plural>/index.jsonthe same list as JSON

Same filter as the calendars — published, audience: :public, unlocked, one locale — and the same reason for it. Both accept:

ParameterMeaning
?from=window start; a date (2026-09-01) or a full ISO-8601 instant
?until=window end, inclusive by date
?page=1-based page number

A bare date is read as a local day in the deployment's event timezone, not a UTC one — ?until=2026-09-30 means through the end of the 30th where the events are, which for a site east of Greenwich is not the same thing.

?from= is clamped forward to the start of the current local day; the JSON response echoes the window that was actually applied. Nothing earlier is stored, so a request for last year would otherwise answer with this year's events while appearing to have searched.

How the ordering works, and what it cannot do

"Next occurrence" is a function of now(), so it is not something sort: can reach. Kiln stores it: next_occurrence_at is written on every save from the document's schedule and recurrence, and an hourly Oban sweep (KILN_OCCURRENCE_SWEEP_CRON, default 50 * * * *) advances rows whose occurrence has gone by. Ordering and paging then happen in Postgres over an index, so an anonymous request expands no recurrences at all.

Backfilling an existing site — automatic

The migration adds the column; it cannot fill it, because the value comes out of the recurrence engine rather than out of other columns. Neither does the sweep — it visits rows whose occurrence has passed, and a NULL has not passed anything. So on a site that had events before upgrading, nothing would ever compute a first value.

This is handled for you. Booting enqueues a backfill job (KilnCMS.Events.BackfillWorker), so the first deploy after upgrading fills the column and the index works with no upgrade step. The job is deduplicated for a day at the database level, so a rolling deploy across replicas queues one between them and a restart loop cannot re-trigger it more than daily.

Because a redundant pass writes nothing, this doubles as a repair pass: a value knocked out of sync by a restore or a direct database edit is corrected on the next boot rather than persisting until someone notices.

Set KILN_OCCURRENCE_BACKFILL_ON_BOOT=false to turn it off and run it yourself, at a time you choose:

mix kiln.occurrences.backfill

Or, in a release image (no Mix):

bin/kiln_cms eval 'KilnCMS.Events.Backfill.run()'

Idempotent, interruptible, and safe on a live site: it writes only the rows whose value actually changes, so a second run writes nothing. --org <uuid> scopes it to one site, --batch <n> sizes the pages.

--all-types widens it beyond event-shaped types. That matters in exactly one case: a type that lost its datetime_range field while holding future-dated values. Those rows are invisible to the sweep (their value has not passed) and to the default pass (their type is no longer event-shaped), so nothing else will ever correct them. It costs a full scan of every content table, which is why it is not the default.

Two consequences worth knowing:

  • The sweep's period is the staleness window. An event that has finished keeps its place in the listing until the next run. Shorten the cron on a site whose events turn over during the day.
  • Only the next occurrence is stored. A window that starts in the future selects documents whose next date falls inside it, not every recurrence inside it — so a weekly gig happening tomorrow does not appear under ?from= next month, even though it recurs into that month. Listing individual occurrences as addressable items is a different feature (an occurrence table), and deliberately not this one.

A page whose slug is a type's URL segment wins over the generated index at that address, so an operator can replace the listing with a hand-built one. Validations.SlugAvailable normally refuses that collision outright; the precedence matters for a type given the segment of a page that already exists.

Structured data

A type declaring one of the schema.org Event types (Event, MusicEvent, Festival, TheaterEvent, ScreeningEvent, CourseInstance, …) fires an Event node on the :json_ld surface, carrying startDate, endDate, and — for a recurring event — eventSchedule, a schema.org Schedule holding the RRULE verbatim.

An Event node carries name and not articleBody or text: those are CreativeWork properties, and emitting one makes the node invalid rather than merely verbose.

Declaring Event on a type with no datetime_range field is allowed and produces an Event with no dates. The type is what an operator says it is.

What is not here yet

A single occurrence is not addressable. "The 14 March instance of a weekly gig" has no URL of its own, cannot be cancelled independently of the rule that generates it (only skipped, via EXDATE), and cannot carry its own venue or price. That needs an occurrence table — one row per expanded occurrence inside a rolling horizon — which is a bigger thing than the sort key #766 shipped and was deliberately not built alongside it.