Moltenship Time
Views

Custom views

Define a view with a pure strategy and a Renderer component.

A view has two halves:

  • a strategy: four plain functions that pick the visible dates, move them, title them, and turn the events into whatever the view needs to draw (its projection);
  • a Renderer: a React component that receives that projection.

Root runs the strategy and passes the result to the Renderer, so the Renderer only draws.

Define a view

This view shows a few days at a time and counts the meetings on each one. Everything it imports comes from @moltenship/calendar.

days-view.tsx
import {
  createCalendar,
  defineCalendarView,
  iteratePlainDates,
  plainDateInterval,
  zonedDayRange,
} from "@moltenship/calendar";

import { eventAdapter, meetings, weekRules, type Meeting } from "./quick-start";

export const daysView = defineCalendarView({
  name: "days",
  defaultOptions: { days: 3 },
  strategy: {
    getRange: (anchor, _environment, { days }) => plainDateInterval(anchor, anchor.add({ days })),
    navigate: (anchor, delta, { days }) => anchor.add({ days: delta * days }),
    getTitle: (range, { locale }) =>
      new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).formatRange(
        range.start,
        range.end.subtract({ days: 1 }),
      ),
    project: ({ snapshot, range, environment }) =>
      Array.from(iteratePlainDates(range), (date) => {
        const day = zonedDayRange(date, environment.timeZone);
        return { date, events: day.interval ? snapshot.queryTimed(day.interval) : [] };
      }),
  },
  Renderer: ({ projection, actions }) => (
    <ol>
      {projection.map(({ date, events }) => (
        <li key={date.toString()}>
          <button type="button" onClick={() => actions.setAnchorDate(date)}>
            {date.toString()}
          </button>{" "}
          {events.length} meetings
        </li>
      ))}
    </ol>
  ),
});

Write strategy before Renderer. TypeScript reads the object in order, so the Renderer's projection gets its type from project.

Register it and set options

Pass views to createCalendar. View names must be unique; createCalendar throws on a duplicate. viewOptions on Root overrides a view's defaultOptions, keyed by view name:

days-view.tsx
export const Calendar = createCalendar<Meeting>()({
  eventAdapter,
  views: [daysView],
});

export function WorkWeek() {
  return (
    <Calendar.Root
      events={meetings}
      defaultAnchorDate={Temporal.PlainDate.from("2026-10-05")}
      defaultTimeZone="Europe/Berlin"
      locale="en-US"
      weekRules={weekRules}
      viewOptions={{ days: { days: 5 } }}
    >
      <Calendar.Title />
      <Calendar.ActiveView />
    </Calendar.Root>
  );
}

The title reads "Oct 5 – 9", and the list has five days.

Strategy

Prop

Type

anchor is the date in the anchorDate state. environment holds locale, timeZone, weekRules, direction, and disambiguation. The functions should be pure: Root calls them during render and reuses the projection until one of its inputs changes.

project receives:

FieldContents
snapshotThe validated, indexed events. Query it with queryTimed and friends.
rangeThe visible dates, from getRange
instantRangeThe same dates as instants in the calendar's time zone, ready for queryTimed
loadRangeThe dates to project: getLoadRange's, or range without it. Always set by Root
environmentLocale, time zone, week rules, direction, disambiguation
resourcesThe resources prop
optionsThe view's options after viewOptions overrides
todayToday's date in the calendar's time zone, or null before a clock is known

today is fixed when Root has now, so server and browser agree. Without now it's null during server rendering and fills in after hydration.

Typed events

Inside project, events are typed unknown unless you say otherwise, because a view can be used by calendars with different records. To work with your own fields, annotate the input:

import type { CalendarViewProjectionInput } from "@moltenship/calendar";

project: ({ snapshot, instantRange }: CalendarViewProjectionInput<DaysOptions, Meeting>) =>
  snapshot.queryTimed(instantRange).map(({ event }) => event.title),

Renderer

The Renderer is a regular component, so it can use hooks. It receives:

PropContents
projectionWhat project returned
rangeThe visible dates
loadRangeThe dates project covered: range, or the strategy's wider getLoadRange
optionsThe view's options
environmentLocale, time zone, week rules, direction, disambiguation
eventsThe snapshot project received, with pending changes applied
actionssetAnchorDate, setView, navigateNext, navigatePrevious, navigateToday, proposeEventChange

actions never changes identity. setView takes any registered view name; an unknown name shows up as an unregistered-active-view diagnostic. proposeEventChange and events are what a view needs to let people drag and resize events.

To declare a Renderer as its own component, type its props with CalendarViewRendererProps:

import type { CalendarViewRendererProps } from "@moltenship/calendar";

function DaysRenderer({ projection }: CalendarViewRendererProps<DaysProjection, DaysOptions>) {
  // …
}

Reading the view from other components

Components inside Root can read the active view without being part of it:

days-view.tsx
export function VisibleRange() {
  const range = Calendar.useVisibleRange();
  if (range === null) return null;
  return (
    <p>
      {range.start.toString()} to {range.end.subtract({ days: 1 }).toString()}
    </p>
  );
}
  • Calendar.useVisibleRange() returns the visible dates, or null when the active view isn't registered.
  • Calendar.useViewProjection() returns the active view's view name, definition, projection, range, instantRange, loadRange, loadInstantRange, options, environment, and events, or null. Check view to narrow projection to that view's type.
  • useNow({ intervalMs }) returns the current instant for a now-line and re-renders on each tick. It's null during server rendering and hydration unless Root has now.
Edit on GitHub

Last updated on

On this page