Moltenship Time
Concepts

Events and adapters

How Time reads your event records, and what happens when one is invalid.

Time has no event class of its own. You pass an array of your own records to Root, and an adapter tells Time how to read each one: its ID, title, time range, and the resources it belongs to.

bookings.tsx
import { createCalendar, defineEventAdapter, type CalendarDiagnostic } from "@moltenship/calendar";

interface Booking {
  readonly id: `booking-${number}`;
  readonly roomIds: readonly `room-${number}`[];
  readonly summary: string;
  readonly startsAt: Temporal.Instant;
  readonly endsAt: Temporal.Instant;
  readonly updatedAt: string;
  readonly locked: boolean;
}

interface Room {
  readonly id: `room-${number}`;
  readonly name: string;
}

const bookingAdapter = defineEventAdapter<Booking>()({
  getId: (booking) => booking.id,
  getTitle: (booking) => booking.summary,
  getInterval: (booking) => ({ kind: "timed", start: booking.startsAt, end: booking.endsAt }),
  getResourceIds: (booking) => booking.roomIds,
  getRevision: (booking) => booking.updatedAt,
  isReadOnly: (booking) => booking.locked,
});

export const BookingCalendar = createCalendar<Booking, Room>()({ eventAdapter: bookingAdapter });

defineEventAdapter<Booking>() is called twice on purpose. The first call fixes the record type, and the second infers the ID types from your accessors. Here booking-${number} stays booking-${number} everywhere Time hands an ID back to you, so you never cast.

getRevision and isReadOnly are optional. The adapter reference lists every field.

Time ranges

getInterval returns one of three kinds of range. Pick the one that matches what the event means:

kindstart and endUse it for
"timed"Temporal.InstantMeetings and calls: exact moments, shown in the calendar's zone
"all-day"Temporal.PlainDateHolidays and vacations: whole dates, the same in every zone
"floating"Temporal.PlainDateTimeWall-clock times with no zone, like "gym at 7:00"

The end is always exclusive. A one-day all-day event on October 8 has end set to October 9. Time and time zones covers this in detail.

IDs

Event and resource IDs are non-empty strings or finite numbers. To check untrusted input before it reaches the calendar, use isEventId and isResourceId:

import { isEventId } from "@moltenship/calendar";

isEventId("booking-12"); // true
isEventId(""); // false
isEventId(Number.NaN); // false

Resources

Resources are what events are assigned to: rooms, people, equipment. Pass your own resource records (anything with an id) to Root as resources, and return their IDs from getResourceIds. An event can belong to no resource, one, or several.

Views can then filter by resource with snapshot.eventsForResource(roomId) or snapshot.queryTimed(range, { resourceIds: [roomId] }). See snapshot queries.

Changing events

Time never edits, saves, or fetches your events. When your data changes, update your own state and pass a new array. To fetch only the dates on screen, load the loadRange that onVisibleRangeChange reports.

Treat your records as immutable: change a record by replacing it with a new object. Time compares the array item by item, so a new array with the same records in the same order counts as unchanged, even if you build it inline with filter or map. When records do change, Time re-reads only the ones that are new objects, and skips those whose getRevision value is unchanged. The performance guide explains how to make the most of that.

Invalid records

By default, an invalid record is skipped and reported. The rest of the calendar keeps working. Reasons include a missing or duplicate ID, an end that isn't after the start, a range of the wrong Temporal type, or an adapter function that throws.

Reports show up in meta.diagnostics and in the onDiagnosticsChange callback on Root:

bookings.tsx
export function reportDiagnostics(
  diagnostics: readonly CalendarDiagnostic<Booking, Booking["id"]>[],
) {
  for (const diagnostic of diagnostics) {
    if (diagnostic.code === "invalid-event") {
      console.warn(`Skipped event #${diagnostic.issue.index}: ${diagnostic.issue.code}`);
    }
  }
}

Pass reportDiagnostics as onDiagnosticsChange. Time calls it after mount and again whenever the list changes; an inline function works too.

To reject bad input instead, pass invalidEventPolicy: "throw" to createCalendar. Time then throws an InvalidCalendarEventSnapshotError during render, with every problem listed on its issues property. Catch it with an error boundary.

Records in Time's own shape

If you're starting from scratch, you can store events in Time's built-in shape (CalendarEvent: an id, title, kind, start, end, optional resourceIds, and your own data) and use createCanonicalEventAdapter() instead of writing an adapter.

Edit on GitHub

Last updated on

On this page