Moltenship Time

Interactions

Save moved, resized, created, and deleted events, show them while they save, and decide which changes to allow.

Time never edits your events itself. When someone moves, resizes, creates, or deletes an event, the calendar proposes a change. Your onEventChange handler saves it and updates your events. While it saves, the calendar shows the change as pending; if saving fails, the event goes back to where it was.

This page uses the Calendar and Meeting from the Quick start.

Saving changes

Pass onEventChange to Root. Without it, the calendar is read-only.

editable-calendar.tsx
export function EditableCalendar() {
  const [meetings, setMeetings] = useState(initialMeetings);
  // Number changes as they start, and remember the newest one applied to each event, so a slow
  // save never replaces a newer saved edit.
  const nextOrder = useRef(0);
  const lastApplied = useRef(new Map<string, number>());

  return (
    <Calendar.Root
      events={meetings}
      onEventChange={async (change) => {
        const order = ++nextOrder.current;
        await saveToServer(change);
        if (change.eventId !== null) {
          if ((lastApplied.current.get(change.eventId) ?? 0) > order) return;
          lastApplied.current.set(change.eventId, order);
        }
        setMeetings((current) => applyChange(current, change));
      }}
      policies={[timedOnly, snapPolicy({ minutes: 15 }), minimumDurationPolicy({ minutes: 15 })]}
      defaultAnchorDate={Temporal.PlainDate.from("2026-10-08")}
      defaultTimeZone="Europe/Berlin"
      locale="en-US"
      weekRules={weekRules}
    >
      <Calendar.ActiveView />
    </Calendar.Root>
  );
}

The handler receives a change object:

FieldContents
cause"move", "resize-start", "resize-end", "create", "delete", or "update"
eventIdThe event's ID, or null for "create"
eventYour record, or null for "create"
originalWhere the event was, as the user saw it, or null for "create"
proposedWhere it should go: a range with kind, start, end, and resourceIds, or null for "delete"
idIdentifies the change within this calendar, such as change-3. It isn't an event ID

applyChange turns a change into new state. A created event needs an ID of your own, usually from your server; this example makes one up. Records you don't touch stay the same objects, which keeps re-rendering cheap:

editable-calendar.tsx
import {
  eventPlacement,
  minimumDurationPolicy,
  moveEventPlacement,
  snapPolicy,
  type CalendarEventChange,
  type CalendarPolicy,
} from "@moltenship/calendar";
import { useRef, useState } from "react";

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

type MeetingChange = CalendarEventChange<Meeting, string, never>;

function applyChange(meetings: readonly Meeting[], change: MeetingChange): readonly Meeting[] {
  if (change.cause === "delete") {
    return meetings.filter((meeting) => meeting.id !== change.eventId);
  }
  if (change.proposed.kind !== "timed") return meetings;

  const { start, end } = change.proposed;
  if (change.cause === "create") {
    const id = crypto.randomUUID();
    return [...meetings, { id, title: "New meeting", color: "blue", start, end }];
  }
  return meetings.map((meeting) =>
    meeting.id === change.eventId ? { ...meeting, start, end } : meeting,
  );
}

Pending changes

How long a change stays pending depends on your handler:

  • A handler that returns nothing (or doesn't await) commits the change right away. Update your events in the same handler.
  • A handler that returns a promise keeps the change pending until the promise settles. The event shows in its new place in the meantime.
    • If the promise resolves, the calendar keeps showing the change until your events next change. From then on your events decide where the event is, so include the saved change in that update, as setMeetings does in the example. If another update arrives first, such as a refetch that doesn't have the change yet, the event moves to where that update puts it.
    • If it rejects or the handler throws, the change is undone.
editable-calendar.tsx
async function saveToServer(change: MeetingChange): Promise<void> {
  const response = await fetch("/api/meeting-changes", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(change),
  });
  if (!response.ok) throw new Error(`Saving failed with ${response.status}`);
}

Pending events have isPending: true in projections, in meta.eventSnapshot, and in Calendar.Event. The built-in views render them at 60% opacity: the week and day views set data-pending on them, and the month and agenda views set aria-busy. Target those attributes to style pending events your own way.

If the same event changes again before the first change finishes saving, the calendar shows the newer change. Your handler still receives both, and their saves can finish in either order, or one can fail. The example numbers each change as it starts. When a save succeeds, it updates the state only if no newer change to that event has been applied yet. So a slow save never replaces a newer saved edit, and if the newer save fails, the older one that succeeded is still shown. Your server needs the same care, for example by rejecting writes that carry an outdated version.

Policies

Policies decide which changes are allowed. Each one looks at a change and accepts it, adjusts it, or rejects it. They run in order, and each sees the change as adjusted by the ones before it. Pass them to Root as policies, as in the example above.

PolicyWhat it does
snapPolicy({ minutes: 15 })Rounds timed changes to a grid in the calendar's time zone
minimumDurationPolicy({ minutes: 15 })Lengthens timed events that would be shorter than the minimum
boundsPolicy({ start, end })Rejects changes that end up outside the given instants
availabilityPolicy({ mode })Blocks changes outside working hours, or saves them with a warning in change.warnings
readOnlyPolicy()Rejects changes to events your adapter marks with isReadOnly. Root always runs it first, so you don't need to pass it.

A policy is a function, so you can write your own. This one keeps meetings out of the all-day row. Policies can also be async, for example to check availability on your server.

editable-calendar.tsx
// Meetings always have a time, so refuse drops onto the all-day row.
const timedOnly: CalendarPolicy<Meeting, string, never> = ({ proposed }) =>
  proposed === null || proposed.kind === "timed"
    ? { type: "accept" }
    : { type: "reject", reason: "meetings-need-a-time" };

Changing events from your own UI

Buttons, forms, and dialogs can propose changes too, with proposeEventChange from useCalendarActions() (or the actions prop inside a view). The change goes through the same policies, pending state, and handler as a drag. The returned promise tells you how it ended; it never rejects.

push-back-button.tsx
export function PushBackButton({ meeting }: { readonly meeting: Meeting }) {
  const actions = Calendar.useCalendarActions();
  const [message, setMessage] = useState("");

  async function pushBack() {
    const result = await actions.proposeEventChange({
      cause: "move",
      eventId: meeting.id,
      proposed: {
        kind: "timed",
        start: meeting.start.add({ hours: 1 }),
        end: meeting.end.add({ hours: 1 }),
      },
    });
    setMessage(result.status === "committed" ? "Moved" : `Not moved: ${result.reason}`);
  }

  return (
    <>
      <button type="button" onClick={pushBack}>
        Push back an hour
      </button>
      <output>{message}</output>
    </>
  );
}

A rejected result has a reason:

reasonMeaning
read-onlyThe event is read-only, or Root has no onEventChange
a policy's reasonA policy rejected it, such as out-of-bounds
supersededA newer change for the same event replaced it
handler-errorYour handler threw or its promise rejected; error has the cause
policy-errorA policy threw
invalid-placementThe proposed range is empty or reversed
unknown-eventNo event has that ID

To create an event, propose { cause: "create", proposed: { kind, start, end } }. To delete one, propose { cause: "delete", eventId }.

Drag, resize, and create

Once Root has an onEventChange handler, the built-in views let people edit events with the mouse, a pen, or touch:

ViewMoveResizeCreate
Week and dayAcross days and times. Dropping on the all-day row makes it all-day.Drag the top or bottom edgeDrag across empty time, or across all-day cells
MonthAcross days. Timed events keep their time.–Drag across days to make an all-day event

Each finished drag sends exactly one change to your handler, so the policies and pending state above apply. A few details:

  • Times snap to 15 minutes, and a resized event keeps at least one 15-minute slot.
  • A movement under 4px is a click, so clicking an event still works. The click that ends a drag doesn't count as a click.
  • Escape cancels a drag, and so does dropping outside the view.
  • On touch screens, press and hold for a moment to start a drag, so swiping still scrolls.
  • The view scrolls when you drag near its top or bottom edge.
  • Read-only events (your adapter's isReadOnly) can't be dragged and have no resize handles.
  • While dragging, the original event dims (it has data-dragging) and a preview follows the pointer. Dragging never re-renders the view.

Clicking an empty slot doesn't create an event; creating needs a drag.

In your own views

The built-in views use useCalendarPointer, and your views can too. Call it in the Renderer with the props the Renderer receives, and put the returned ref on the element that contains the days:

my-week-view.tsx
import { defineCalendarView, timeGridStrategy, useCalendarPointer } from "@moltenship/calendar";

export const myWeekView = defineCalendarView({
  name: "week",
  strategy: timeGridStrategy({ kind: "week" }),
  Renderer: function MyWeek({ actions, environment, events, projection }) {
    const ref = useCalendarPointer({
      columns: projection.days.map((day) => day.column),
      events,
      onPropose: actions.proposeEventChange,
      timeZone: environment.timeZone,
    });

    return <div ref={ref}>{/* Day columns with data-date, events with data-event-id */}</div>;
  },
});

Pass columns only in time-grid views; a month grid finds days by their data-date. The hook reads these attributes from your markup:

ElementAttribute
A day column in a time grid, covering 24 hoursdata-date="2026-10-08"
An all-day celldata-date="…" data-all-day
A day cell in a month griddata-date="…"
An event (render it as a <button>)data-event-id
A resize handle inside an eventdata-resize="start" or "end"
A control that never starts a dragdata-calendar-pointer="none"

The pointer layer reference lists the hook's options and styling hooks.

Edit on GitHub

Last updated on

On this page