Hapio Calendar

Core

@hapio/calendar-core: views, events, sources, rules, callback payloads, formats, strings, zones and the store.

@hapio/calendar-core is framework-free and safe to import in Server Components. @hapio/calendar-react re-exports all of it, so client code imports from there; server code imports from here.

Views

function defineViews<const V extends Record<string, ViewDefinition>>(views: V): V;

Declares a view map and keeps its literal names, so ViewName<typeof views> is a union of them. Returns views itself; the first key is the default view.

function timeGrid(o?: TimeGridOptions): ViewDefinition<"timeGrid", TimeGridOptions>;

A time-grid view (day columns over a time axis).

Options of timeGrid() (day columns over a time axis).

Prop

Type

function dayGrid(o?: DayGridOptions): ViewDefinition<"dayGrid", DayGridOptions>;

A day-grid view (week rows of day cells).

Options of dayGrid() (week rows of day cells).

Prop

Type

function list(o?: ListOptions): ViewDefinition<"list", ListOptions>;

A list view (events grouped by day).

Options of list() (events grouped by day).

Prop

Type

const defaultViews: DefaultViews;

Week and day time grids, a month grid and a week list. The default view is week.

getVisibleRange

function getVisibleRange(view: ViewDefinition, o: GetVisibleRangeOptions): VisibleRangeResult;

The range a view shows for a date, as onRangeChange would report it. Safe on the server and in RSC: it never reads the clock (date: 'today' needs now, otherwise it throws a TypeError). Throws a RangeError for an unknown view kind, an invalid view option (including hiddenDays that hide all seven weekdays), an invalid date or an unknown zone.

getVisibleRange(defaultViews.week, { date: '2026-10-21', timeZone: 'Europe/Stockholm' })
// { startIso: '2026-10-19T00:00:00+02:00', endIso: '2026-10-26T00:00:00+01:00',
//   currentStartIso: '2026-10-19', currentEndIso: '2026-10-26' }

Options of getVisibleRange.

Prop

Type

The range a view shows for a date, in the shape onRangeChange reports. startIso/endIso are true instants (start of the first and after the last visible day) as ISO strings with offset, e.g. '2026-10-19T00:00:00+02:00'. currentStartIso/currentEndIso are the navigation period as 'YYYY-MM-DD' dates with an exclusive end.

Prop

Type

Events

An event input carrying app data.

Prop

Type

Dates are DateInput, all-day dates PlainDateInput, times of day TimeInput and durations DurationInput:

type DateInput = Date | number | string | InstantLike | ZonedDateTimeLike | PlainDateLike | PlainDateTimeLike;

A date or date-time. How each form is read:

InputMeaning
Date, epoch-ms number, Instant, ISO string with Z or an offsetinstant
ZonedDateTime, ISO string with [Zone] (offset optional)instant plus source zone
PlainDate, 'YYYY-MM-DD'all-day (epoch days)
PlainDateTime, offset-less 'YYYY-MM-DDTHH:mm[:ss[.sss]]'floating: resolved in the calendar zone with 'compatible'
type PlainDateInput = string | PlainDateLike;

A calendar date: 'YYYY-MM-DD' or a PlainDate.

type TimeInput = string | PlainTimeLike;

A time of day: 'HH:mm', 'HH:mm:ss', '24:00' (end of day) or a PlainTime.

type DurationInput = string | number | {
  weeks?: number;
  days?: number;
  hours?: number;
  minutes?: number;
  seconds?: number;
} | DurationLike;

A duration: ISO 8601 ('PT30M', 'P1D'), clock form ('00:30', '01:30:00'), a number of milliseconds, a field object or a Temporal.Duration.

Which edits are allowed. editable: true ⇒ { move: true, resizeEnd: true, resizeStart: false, resource: true }.

Prop

Type

What callbacks receive

type CalendarEvent<TData = undefined> = TimedEvent<TData> | AllDayEvent<TData>;

A timed event.

Prop

Type

An AllDayEvent has the same fields with allDay: true and start and end as Temporal.PlainDate (the end exclusive), and no startMs or endMs.

Background events, resources and business hours

A background event: shading, a constraint target, or both.

Prop

Type

A resource (a lane).

Prop

Type

type BusinessHoursInput = boolean | BusinessHoursRule | readonly BusinessHoursRule[];

true = Monday–Friday 09:00–17:00.

A weekly business-hours rule. end may be '24:00'.

Prop

Type

Sources

An async source of events (or of accessor-mode items).

  • Sources are keyed by id. The latest fetch is read from props at call time, so changing a source's identity does not refetch; a new range key, window or zone does, and so does commands.refetch(id).
  • A request superseded by a newer one (navigation, zone change, unmount, <Activity> hidden) is aborted through ctx.signal; its result is ignored even if it resolves.
  • window widens requests to whole weeks or months in the calendar zone (the reference app's "open time range"), so navigating within the window does not refetch. Default 'view'.
  • keepPrevious (default true) keeps the previous result visible while the next range loads; with false the source shows nothing until its new result arrives.
  • color, className, editable and rules apply to every event of the source (event > source > resource > calendar).

Prop

Type

The range an event source is asked for: true instants (exclusive end) in the calendar zone, widened by the source's window.

Prop

Type

Rules

type Constraint = "businessHours" | {
  readonly group: string;
} | {
  readonly start?: DateInput;
  readonly end?: DateInput;
  readonly days?: readonly IsoWeekday[];
  readonly startTime?: TimeInput;
  readonly endTime?: TimeInput;
  readonly resourceIds?: readonly string[];
} | ((span: DateSpan) => boolean);

Where an edit or selection may land. A list means all must hold.

  • 'businessHours': inside business hours of the lane.
  • { group }: fully inside the union of background events with that group, in the same lane.
  • an object: inside a date range, on given weekdays, inside a daily time window, on given resources.
  • a function: any test on the span.

Rules for moving, resizing and dropping events.

Prop

Type

Rules for selections.

Prop

Type

A proposed edit, as rules.event.allow sees it. kind is EventChange['kind'] or 'external'; via is EventChange['via'] or 'native'.

Prop

Type

A broken rule: why a selection (M4) or an edit (M5) cannot land where it is proposed. The announcement and the slot cursor's name say it in words: message when allow returned a string, else strings.ruleViolation(reason).

Prop

Type

type RuleViolationReason = "valid-range" | "not-editable" | "constraint" | "overlap" | "allow";

Which rule a proposal breaks, in evaluation order (the first failure wins):

  • 'valid-range': it reaches outside validRange;
  • 'not-editable': the event's editable flags forbid the change (edits, M5; never for selections);
  • 'constraint': it is not fully inside every constraint (businessHours, a background group, an object constraint, a function);
  • 'overlap': it overlaps an event that overlap does not allow (background events never count);
  • 'allow': allow returned false or a reason string.

Callback payloads

type DateSpan = EventTiming & {
  readonly resourceId: string | null;
};

A range in a lane. resourceId is always present; null outside resource lanes.

type EventTiming = {
  readonly allDay: false;
  readonly start: Temporal.ZonedDateTime;
  readonly end: Temporal.ZonedDateTime;
  readonly startIso: string;
  readonly endIso: string;
} | {
  readonly allDay: true;
  readonly start: Temporal.PlainDate;
  readonly end: Temporal.PlainDate;
  readonly startIso: string;
  readonly endIso: string;
};

onRangeChange, onNavigate

What onRangeChange reports: the active view's range after a commit, deduplicated by view|startIso|endIso|timeZone (exactly once per range, StrictMode included). It first fires once the clock is known when the range depends on it.

Prop

Type

What onNavigate, onDateChange and onViewChange callers learn about a navigation.

Prop

Type

onEventClick, onDateClick, onSelect

onEventClick payload.

Prop

Type

onDateClick payload (M4): a click or tap on an empty part of a time-grid lane (the slot under the pointer), an all-day cell or a day cell (the day), or Enter on the slot cursor or a day cell without a nav link. span is that slot (one snap, DST-correct, timed) or day (all-day, exclusive end).

Prop

Type

onSelect payload (M4): a completed selection, after the rules accepted it. span is DST-correct: timed spans are true instants with each end's own offset (Stockholm 25 October, 01:30 to 03:00 wall time: startIso 2026-10-25T01:30:00+02:00, endIso 2026-10-25T03:00:00+01:00); all-day spans are dates with an exclusive end (2026-10-24 to 2026-10-27 for 24–26 October). span.resourceId is null until resource views (1.3).

Prop

Type

onEventChange, onExternalDrop

A move or resize, as onEventChange receives it (M5). The pending overlay already shows the event (and its related events) at next while the app answers; return (or resolve) false, throw or reject to revert it.

Prop

Type

type CommitResult = void | boolean | Promise<void | boolean>;

What onEventChange/onExternalDrop return. false, a throw or a rejection reverts.

An external drop (M6), as onExternalDrop receives it, with data narrowed by acceptExternal (an item of useExternalDraggable) or nativeDrop.accept (a native drag, such as files). Until the app answers and its events change, the calendar draws a placeholder where the item landed: return (or resolve) false, throw or reject to take it away again.

Prop

Type

Formats

Every format the views use; formats props take a Partial of this.

Prop

Type

Post-processing applied to formatToParts output:

  • omitCommas: drop literal commas ("Thu, 29 Oct" → "Thu 29 Oct").
  • forceCommas: put a comma after the weekday where the locale has none.
  • omitZeroMinute: "9:00 AM" → "9 AM" (only when the minute is zero).
  • meridiem: 'short' ("am"), 'narrow' ("a") or false (drop the day period).

Prop

Type

type FormatFunction = (value: Temporal.ZonedDateTime | Temporal.PlainDate, ctx: FormatContext) => string;

A custom format. Timed values arrive as ZonedDateTime, all-day values as PlainDate.

Strings

Every user-visible string. strings props take a Partial of this; en is built in and @hapio/calendar-core/locales/sv is a full Swedish pack. The English default of each member is given in its comment; the reference app's specs rely on the toolbar, view and list names.

Later milestones add members (keyboard, editing and announcement texts): M3 close, M4 the selection texts (available … selectionCleared), M5 grabHint and the optional editing texts (grabbed … finishChangeFirst), whose English defaults live with the editing engine (editing()), so calendars without it never bundle them; a full pack may leave them out.

Prop

Type

The Swedish pack is sv from @hapio/calendar-core/locales/sv, the built-in English en from @hapio/calendar-core/locales/en.

Zones and the clock

function normalizeTimeZone(id: string): string;

The current IANA name of a zone id: legacy aliases map to current names (Asia/Calcutta → Asia/Kolkata, Europe/Kiev → Europe/Kyiv), case is canonicalized and the id is validated through Intl. 'UTC' (and its aliases such as 'Etc/UTC') gives 'UTC'. 'local' is returned unchanged: it is a sentinel the calendar resolves on the client. Throws a RangeError for an unknown zone.

function sameTimeZone(a: string, b: string): boolean;

Whether two zone ids name the same zone after normalization. Use this, never ===. 'local' equals only 'local'; an unknown id equals nothing (no throw).

function isPlainDateString(s: string): boolean;

Whether s is a strict YYYY-MM-DD calendar date that round-trips (no 2026-02-30, no extended years, no surrounding whitespace). Safe for URL-seeded dates.

A source of "now". The library never reads the wall clock itself; it asks a clock. now() returns epoch ms. subscribe asks for a tick at least every resolutionMs (aligned to that boundary) and returns an unsubscribe function; timers run only while something is subscribed.

Prop

Type

function systemClock(): ClockSource;

The wall clock. now() reads Date.now() at call time (so Playwright's page.clock and Vitest fake timers work; never a Date subclass). subscribe(onTick, resolutionMs) ticks with setTimeout aligned to the next multiple of resolutionMs (minute boundaries for 60_000); timers are shared per resolution, ref-counted, and stop when the last subscriber leaves.

function fixedClock(at: DateInput): ClockSource;

A clock stopped at at (an instant, or a floating/all-day input read as UTC). Its subscriptions never tick.

The store

The framework-free store behind useCalendar, for bindings and tests.

function createCalendarStore<TData = undefined, TRes = undefined, const V extends ViewMap = DefaultViews, TDrag = never>(init: CalendarOptions<TData, TRes, V, TDrag>, env?: CalendarStoreEnv): CalendarStore<TData, TRes, V, TDrag>;

Creates a calendar store from the initial options. Pure: no timers, listeners, fetches or clock reads until connect()/sync(). The clock is env.clock, else options.now (a ClockSource, or a fixed instant), else systemClock() (client only; the server snapshot has no clock unless now is given).

const store = createCalendarStore({ events, timeZone: 'Europe/Stockholm', date: '2026-10-21' })
const model = store.resolve(options, store.getState())

The framework-free calendar store. React uses it through useCalendar:

  • createCalendarStore has no side effects: no timers, listeners or clock reads until connect().
  • resolve is pure: the same options and state give an equal model (memoized per field identity and per layout bucket); it never emits and never reads the clock.
  • commit runs in a layout effect and only records the committed model and the latest-options getter (hit-testing, rules and commands use them). It never emits.
  • sync runs in a passive effect after every commit: it starts and aborts fetches, resolves the clock, fires onRangeChange (deduplicated by range key), onLoadingChange and the navigation announcement. Idempotent, so StrictMode re-runs are harmless.
  • connect starts the clock subscription and returns disconnect; it is idempotent and ref-counted, so StrictMode and <Activity> hide/show work: disconnecting stops the clock and aborts fetches, and state is kept.

getState, getServerState, subscribe and commands are bound, so they can be passed unbound (e.g. to useSyncExternalStore); call the other members on the store.

TData is invariant here (options both produce and consume event data), so a typed store does not assign to CalendarStore<unknown>; untyped holders use CalendarStore<any, any, any, any>.

Prop

Type

Commands. Stable identity for the store's lifetime. Each command is a bound function property (not a method), so destructuring and passing them as handlers is safe (onClick={commands.next}). On a controlled value they call the matching on*Change instead of changing state.

Prop

Type

What store.resolve returns and every hook reads: an immutable snapshot of the calendar for one render. Fields keep their identity while their inputs are unchanged, and segments are shared by key across rebuilds, so memoized components skip work.

Prop

Type

What store.hitTest finds under a viewport point (M6; for drops of third-party drag-and-drop libraries such as dnd-kit or Pragmatic drag and drop): the lane (TimeGridLane.key) or day (DayGridDay.key) and the span of the slot ('time': one snap of a lane's body, the wall time under the point resolved 'compatible') or the day ('day-row': an all-day cell or a day-grid cell, an all-day span of one day). 'list' is reserved: lists have no geometry and never hit in v1.

Prop

Type