Moltenship Time

Availability

Working hours, closed days and holidays, people and rooms in their own time zones, shading you can restyle, a busy or free status, and a policy that keeps bookings inside working hours.

Live, in your time zone. Tab into the calendar or click a day, then try the keys below.

Business hours are open, and everything else is shaded: the evenings, the weekend and Friday's holiday. Availability is when the calendar, or a person or a room, can be booked. Views shade the time outside it, the month mutes closed days, and a policy decides what happens to a change that lands outside it: the change can be blocked, or saved with a warning. Drag an event into the evening above and the screen reader strip says "outside working hours". Try it with people in other time zones in the live demo, where the Working hours setting switches between hidden, shown, warn and block.

Add working hours

Pass the calendar's hours to Root as availability, and add availabilityPolicy to its policies:

import { availabilityPolicy, type CalendarAvailability } from "@moltenship/calendar";

const time = Temporal.PlainTime.from;

const officeHours: CalendarAvailability = {
  weekly: [{ days: [1, 2, 3, 4, 5], start: time("09:00"), end: time("17:00") }],
  exceptions: [
    { date: Temporal.PlainDate.from("2026-12-25"), label: "Christmas" },
    {
      date: Temporal.PlainDate.from("2026-12-24"),
      hours: [{ start: time("09:00"), end: time("12:00") }],
    },
  ],
};

const policies = [availabilityPolicy<Meeting, string, string>({ mode: "warn" })];

<Calendar.Root availability={officeHours} policies={policies} {...props} />;

Without availabilityPolicy, views still shade the hours, but nothing stops a change outside them.

Hours and exceptions

  • Weekly hours. Each rule opens start to end on its days, ISO days of the week from 1 (Monday) to 7 (Sunday). A day can have several rules, such as a lunch break between two. An end at or before start runs past midnight, so 22:00 to 06:00 is a night and 00:00 to 00:00 a whole day. Leave weekly out to open every day all day.
  • Exceptions. An exception replaces one date's hours. Without hours it closes the date; its label, such as "Christmas", names the closed day in the month.
  • Time zones. The hours are on the display time zone's clock unless availability.timeZone says otherwise, such as the office's.
  • Daylight saving time. Hours are half-open, [start, end), and read on the wall clock. On a spring-forward day, hours that start in the skipped hour open when the clock jumps. On a fall-back day, hours that end in the repeated hour close after the repeat, so they cover both.

Invalid rules, exceptions and time zones are left out and reported through onDiagnosticsChange as invalid-availability. The calendar never throws over them.

People and rooms

Live, in your time zone. Drag events between rooms and people, or resize and create them.

Resources follow the calendar's hours unless they have their own:

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

const weekdays = (start: string, end: string): AvailabilityRule => ({
  days: [1, 2, 3, 4, 5],
  start: Temporal.PlainTime.from(start),
  end: Temporal.PlainTime.from(end),
});

const resources = [
  {
    id: "orchid",
    title: "Orchid",
    data: room,
    availability: { weekly: [weekdays("08:00", "20:00")] },
  },
  { id: "alex", title: "Alex", data: person, timeZone: "America/Los_Angeles" },
  { id: "lee", title: "Lee", data: person, timeZone: "Asia/Kolkata" },
];
  • Own hours. A resource's weekly replaces the calendar's. Its exceptions add to the calendar's, so a company holiday closes every room, and the resource's own win on the same date.
  • Own time zone. A resource with a timeZone keeps its hours on that clock: Alex's 9 to 5 in Los Angeles shows as the evening in Berlin.
  • One time axis. Resource columns and the timeline keep a single axis in the display time zone. Only the shading follows each resource's clock, and its header shows its local time and UTC offset, such as "7:18 AM GMT-7", while that clock differs from the display time zone's.
  • The same instant. Moving an event to a resource in another time zone keeps its instant: a 9:00 call in New York stays 9:00 in New York, which is 14:00 for London.

Customize the shading

Each stretch of closed time is a div hidden from screen readers. It carries why it is closed and where it meets open time, so CSS can restyle it:

Attribute or variableValue
data-slottime-grid-unavailable, resource-timeline-unavailable or month-closed
data-reasonoutside-hours, closed-day (no weekly hours that day) or exception (a closed date)
data-labelThe exception's label, such as "Company holiday"
data-opens-before, data-opens-afterPresent where open time meets the band's start or end in the view
--band-start, --band-endWhere the band starts and ends along its column or row, as percentages
Live, in your time zone. Switch the recipe, and the theme, to see each in light and dark.
globals.css
/* Gradient: closed time fades out over the last hour and a half before working hours. */
[data-slot="time-grid-unavailable"] {
  --calendar-unavailable: color-mix(in oklch, var(--muted-foreground) 16%, transparent);
  /* Never more than half the band, so a short closure between open hours still shows. */
  --fade: min(calc(var(--calendar-hour-height) * 1.5), 50%);
}
[data-slot="time-grid-unavailable"][data-opens-after] {
  mask-image: linear-gradient(to bottom, black calc(100% - var(--fade)), transparent);
}
[data-slot="time-grid-unavailable"][data-opens-before] {
  mask-image: linear-gradient(to top, black calc(100% - var(--fade)), transparent);
}
[data-slot="time-grid-unavailable"][data-opens-before][data-opens-after] {
  mask-image: linear-gradient(
    transparent,
    black var(--fade),
    black calc(100% - var(--fade)),
    transparent
  );
}

/* Hatching, with holidays tinted. */
[data-slot="time-grid-unavailable"] {
  background: repeating-linear-gradient(
    -45deg,
    transparent 0 6px,
    color-mix(in oklch, var(--muted-foreground) 25%, transparent) 6px 7px
  );
}
[data-slot="time-grid-unavailable"][data-reason="exception"] {
  background-color: color-mix(in oklch, var(--brand) 8%, transparent);
}

/* A brand tint, through the token every view reads. */
:root {
  --calendar-unavailable: color-mix(in oklch, var(--brand) 12%, transparent);
}

The default tint is --calendar-unavailable for closed time and --calendar-closed for closed days in the month. In the dark theme closed time is lighter than open time, since a near-black surface has little room to get darker. Recipes built on --muted-foreground or your own tokens follow the theme the same way.

To draw something else, pass renderUnavailable to a view. It works like Base UI's render: give it an element, or a function of the band's props and its state. The state holds the band (its reason, label and edges), the column's date and the resource whose hours it shows, so rooms can look different from people. Keep the props on your element: they place it and keep it hidden from screen readers.

import type { UnavailableTimeRender } from "@/components/calendar/unavailable-time";

const tintRooms: UnavailableTimeRender = (props, { resource }) =>
  // Views hand over resources as the root got them; read your own fields from `data`.
  (resource?.data as Room | Person | undefined)?.kind === "room" ? (
    <div
      {...props}
      style={{
        ...props.style,
        "--calendar-unavailable": "color-mix(in oklch, var(--color-sky-500) 14%, transparent)",
      }}
    />
  ) : (
    <div {...props} />
  );

const views = [
  createWeekView<Meeting>({ renderUnavailable: tintRooms }),
  createResourceColumnsView<Meeting, Room | Person>({ renderUnavailable: tintRooms }),
];

Every view takes renderUnavailable: week, day, month, the scrolling views, resource columns and the timeline.

Busy or available

Working hours and events answer two different questions. Unavailable means outside working hours. Busy means an event occupies the time. A person can be busy outside their hours, at a late call, or free during them. The status API keeps the two apart:

import { availabilityAt, busyAt, resourceStatusAt } from "@moltenship/calendar";

availabilityAt(availability, now, "maya");
// { available: true, closesAt: Temporal.Instant }
// { available: false, reason: "outside-hours" | "closed-day" | "exception", label?, opensAt }

// `events` is the root's event snapshot, as views get it in their props.
busyAt(events, now, { resourceId: "maya", timeZone: "Europe/Berlin" });
// { busy: true, events: [...], freeAt: Temporal.Instant }
// { busy: false, nextEventAt: Temporal.Instant | null }
  • Time zones. Hours are read on the resource's own clock, as the shading is, and are safe across daylight saving changes. All-day and floating events count on the wall clock you pass, usually the display time zone, as the views show them.
  • Which events are busy. Timed and floating events are; all-day ones, such as a holiday banner, aren't, as in Google Calendar. The event adapter's isBusy overrides that; see Customize.
  • Overlaps. events lists everything in progress. freeAt is when the last of a run of overlapping or back-to-back events ends, so a 10:00 standup followed by an 11:00 review is busy until the review ends.
  • Horizons. opensAt and closesAt look up to a year ahead, and freeAt and nextEventAt a month ahead. They are null past that.

In React, useResourceStatus(resourceId, at?) returns both, plus changesAt, the next instant either can change. Without at it follows the calendar's clock or now. It re-renders only when the status changes, waking at changesAt rather than on every tick. It is null while server rendering, unless the calendar fixes now.

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

function Presence({ id }: { id: string }) {
  const status = useResourceStatus(id);
  if (status === null) return null;
  return status.busy.busy ? "In a meeting" : status.availability.available ? "Around" : "Away";
}

Busy badge in resource views

Live, in your time zone. Drag events between rooms and people, or resize and create them.

Set resourceStatus: true on the resource columns or timeline view to show a badge on each resource:

StateShows
Busy"Busy until 3:00 PM", while events occupy it
Free"Free until 5:00 PM", until its next event or closing time
Outside hours"Outside hours until Mon 9:00 AM", until it opens
Closed today"Closed today", or "Closed today, Company holiday"

The words carry the state; the dot before them is colored with --calendar-busy or --calendar-free. Times are on the display clock, with the weekday within the next six days and the date further out. Busy wins over the hours, so a late call shows as busy. The badge is shadcn's Base UI Badge. In your own ResourceHeader, add ResourceHeaderStatus where you want it:

import {
  ResourceHeaderStatus,
  ResourceHeaderTitle,
  ResourceHeaderToggle,
} from "@/components/calendar/resource-columns-view/resource-header";

function ResourceHeader() {
  return (
    <>
      <ResourceHeaderToggle />
      <span className="flex min-w-0 flex-col">
        <ResourceHeaderTitle />
        <ResourceHeaderStatus />
      </span>
    </>
  );
}

ResourceHeaderStatus shows nothing for a group, whose own bookings aren't its members', or without resourceStatus, which makes room for it in the header. For a badge anywhere else, useResourceStatusSummary(environment, resourceId) from resource-status.tsx returns the words and state, and ResourceStatusBadge draws them; environment is the locale and timeZone a view gets in its props.

Scheduling: who's busy?

Live, in your time zone. Drag events between rooms and people, or resize and create them.

While you place an event, by dragging or with the keyboard, each resource's header says whether it's busy then: Busy when an event overlaps the draft, Outside hours when the time is outside its hours, Free otherwise. Create or move an event above, and watch the row headers. The draft's own event never conflicts with itself, and events that only touch don't conflict.

It all rests on one function. busyResources answers for many resources at once, with one query of the event index, bucketed by each event's resources, so a busy person never marks the others:

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

const busy = busyResources(
  events,
  { start, end },
  {
    exclude: "evt_42", // the event being moved, if any
    resourceIds: ["room-orchid", "room-lotus", "maya"], // or leave out for every resource
    timeZone: "Europe/Berlin", // for all-day and floating events
  },
);
busy.get("maya"); // [standup, review]: the events that make Maya busy then
busy.has("room-lotus"); // false: free resources are absent

The range can also be a placement, all-day or floating, read on timeZone. In React:

HookReturns
useBusyResources(range, opts?)busyResources over the root's events, once per change of events or range
useCalendarDraft()The change being placed, by pointer or keyboard: { eventId, proposed }, or null
useDraftBusy(resourceId)A resource during the draft: { events, outsideHours }, or null without one

Drafts step by slots, so these change at most once a step. useDraftBusy works the draft's busy resources out once for every header that asks. During a pointer drag or a keyboard create, a header re-renders only when its own answer changes; a keyboard move or resize changes the events the view shows on each step, so headers re-render then too, which stays cheap.

An all-day draft shows who has busy time that day, even though an all-day event is free by default and so doesn't conflict under conflictPolicy; the status is information, not a rule. To flag or prevent double-booking on save, add conflictPolicy({ mode: "warn" | "block" }) to the root's policies; it runs the same query, with the reason "resource-conflict".

Customize

Everything the views draw is a layer over these hooks, so you can replace any of it.

Your own status in the header. Pass resourceStatus options instead of true. DraftStatus draws the status during a draft in place of the badge, and gets the resource, its busy events and whether the draft is outside its hours. Headers also carry data-busy and data-outside-hours during a draft, so CSS alone can style them.

function AvatarRing({ resource, events, outsideHours }: ResourceDraftStatusProps) {
  const ring =
    events.length > 0 ? "ring-red-500" : outsideHours ? "ring-amber-500" : "ring-green-500";
  return (
    <span
      className={`size-5 rounded-full ring-2 ${ring}`}
      title={events.map((e) => e.title).join(", ")}
    />
  );
}

createResourceColumnsView<Meeting>({ resourceStatus: { DraftStatus: AvatarRing } });
[data-slot="resource-header"][data-busy] {
  background: color-mix(in oklch, var(--calendar-busy) 12%, transparent);
}

Your own words. labels replaces the English the badge shows and screen readers hear, now and during a draft. until is a time on the display clock, absent during a draft:

createTimelineWeekView<Meeting>({
  resourceStatus: {
    labels: {
      busy: (until) => (until ? `Occupé jusqu'à ${until}` : "Occupé"),
      free: (until) => (until ? `Libre jusqu'à ${until}` : "Libre"),
      outsideHours: (until) => (until ? `Hors horaires jusqu'à ${until}` : "Hors horaires"),
      closedToday: (label) => (label ? `Fermé aujourd'hui, ${label}` : "Fermé aujourd'hui"),
    },
  },
});

Your own idea of busy. The event adapter's isBusy is the one place that decides, for the status, the draft headers, busyResources and conflictPolicy alike:

const eventAdapter = defineEventAdapter<Meeting>()({
  // ...getId, getInterval, getResourceIds, getTitle
  // Focus time and tentative meetings leave you bookable; all-day ones block when marked busy.
  isBusy: (meeting) =>
    meeting.title !== "Focus time" &&
    meeting.showAs !== "tentative" &&
    (meeting.kind !== "all-day" || meeting.showAs === "busy"),
});

Your own "who's busy" UI. Any component can ask. The live demo's event dialog has a room and people picker split into Available and Busy, built on useBusyResources rather than shipped as a part; busy rooms stay selectable, since a double booking can be on purpose:

function RoomPicker({ interval, eventId }: { interval: CalendarEventInterval; eventId?: string }) {
  const busy = useBusyResources(interval, { exclude: eventId });
  const [free, taken] = partition(rooms, (room) => !busy.has(room.id));
  return (
    <>
      <Group title="Available" rooms={free} />
      <Group
        title="Busy"
        rooms={taken}
        chip={(room) =>
          busy
            .get(room.id)!
            .map((e) => e.title)
            .join(", ")
        }
      />
    </>
  );
}

Keep changes inside

availabilityPolicy({ mode }) checks every move, resize and create, whether it comes from a drag, the keyboard, or actions.proposeEventChange. An event with resources must fit the hours of each resource it books, on that resource's clock: its own hours, or the calendar's when it has none. So a room open until 20:00 takes an 18:00 booking even when the office closes at 17:00. An event without resources must fit the calendar's hours. A timed event must fall in open time, and an all-day event on dates with open time. Only what a change alters is checked, so events already outside the hours can still be edited.

ModeOutside the hours, the change is…
"block"Rejected with the reason "outside-availability", and the event goes back where it was.
"warn"Saved, with "outside-availability" in its warnings, so you can say so or ask to confirm.

While a gesture is in progress, its preview shows what the policies would say: a drag preview or a keyboard-moved event gets a dashed outline, red when the change would be rejected and amber when it would be saved with a warning. Your own UI can ask the same question with actions.checkEventChange(proposal), which runs the policies without saving anything.

Keyboard and screen readers

Availability adds no keys and no tab stops; each view keeps its key map. The shading is hidden from screen readers, which hear the same information in words:

WhereWhat a screen reader hears
A time-grid slot, with ↑ ↓"Thursday, October 8 at 8:45 AM, outside working hours"
A month day or a timeline day"Friday, December 25, 2026, Christmas, closed, no events"
A step of a move, resize or create"Thursday, October 8, 4:45 – 5:15 PM, outside working hours"
A blocked drop"Standup can't go outside working hours, so it's back where it was."
A saved change with a warning"Moved Standup to Thursday, October 8, 4:45 – 5:15 PM, outside working hours."
A resource header in another zone"Lee, local time 7:48 PM GMT+5:30"
A resource header with its status"Maya, Busy until 3:00 PM"
A resource header during a draft"Maya, Busy", or "Leo, Outside hours"

The messages live in announcements.ts in your copy of the registry source, ready to translate.

Anatomy

  1. Time gridTimeGridView
  2. Day headerTimeGridHeader
  3. All-day rowTimeGridAllDay
  4. Title
  5. Time
  6. Timed eventTimeGridEvent
  7. Resize handledata-resize="end"
  8. Time gutter--calendar-gutter-width
  9. Outside working hoursTimeGridAvailability
  10. Now lineuseNow
  11. Overlap lanesTimeGridEventLayout
  12. Day columnTimeGridColumn
Rendered by TimeGridColumn
TimeGridColumn                   time-grid-column.tsx
├── TimeGridAvailability         time-grid-availability.tsx, data-slot="time-grid-unavailable"
├── TimeGridAnnotations          time-grid-annotations.tsx
└── TimeGridEvent                time-grid-event.tsx
  • Time grid and resource columns. TimeGridAvailability draws an UnavailableTime band behind the events for each closed stretch of a column, in --calendar-unavailable.
  • Timeline. TimelineAvailability shades each resource's closed time along hour rows, and its closed days along day rows, whose cells also get data-closed.
  • Month. A closed day gets a month-closed band in --calendar-closed, light enough that its date and events stay readable, and its cell gets data-closed.
  • Resource headers. ResourceHeaderTime shows a resource's local time and ResourceHeaderStatus its status; add them to your own ResourceHeader composition.

Your own view

The shading is separate from the event projection, like annotations, so working hours never change event layout. These hooks give a view what to draw, for the calendar's hours or, with a resource ID, that resource's:

HookReturns
useTimeGridAvailability(columns, id?)The closed bands of each time-grid column, as fractions of 24 h
useTimelineAvailability(columns, id?)The closed stretches along a timeline, as fractions of its width
useClosedDates(range, id?)The dates with no open time, keyed by ISO date, with a reason and label
useResourceStatus(id?, at?)Working hours and events at an instant, and when either changes
useCalendarAvailability()The resolved hours: calendar, forResource(id), timeZone

Bands and stretches carry their reason, label, opensBefore and opensAfter, as the attributes above do.

Each schedule answers available(range), unavailable(range) and isAvailable(range) for any instant range, and resolves each date once, so views can ask on every render.

Data model

Prop

Type

A resource's availability takes weekly and exceptions, and its timeZone sits on the resource itself.

Edit on GitHub

Last updated on

On this page