02examples
Booking calendar
A Cal.com-style availability picker — busy time hatched and refused by constraints, click a free slot to book.
Three days of a host's calendar, clipped to 9–18. The hatched bands are her busy time — not events, just refused time the grid draws and defends. Click any free half-hour and a green draft appears with a confirm card beside it; drag or resize the draft to adjust it, then confirm. Try dragging the draft into a hatch: the snap holds it out.
liveBooking calendarconstraintsisSlotDisabledslotAllowedonSlotClick
loading
"use client";
import { useMemo, useState } from "react";
import {
Kloq,
createConstraints,
formatViewTitle,
localZone,
slotAllowed,
useIsClient,
useKloq,
type CalEvent,
} from "kloq";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DRAFT_ID,
HOST,
asDraft,
busyCalendar,
confirmBooking,
draftBooking,
hostConstraints,
whenLabel,
} from "./data";
// the visitor never sees an editor: the confirm card on the left is the panel
const SLOTS = { EventPanel: () => null };
export default function BookingPageExample() {
const [events, setEvents] = useState<CalEvent[]>([]);
const [busy] = useState(() => busyCalendar()); // what a fetch would set
const [booked, setBooked] = useState<CalEvent | null>(null);
const draft = events.find((e) => e.id === DRAFT_ID);
// busy windows + host hours + confirmed bookings; rebuilt as bookings land
const constraints = useMemo(() => hostConstraints(busy, events), [busy, events]);
const onEventsChange = (next: CalEvent[]) => {
const confirmed = next.filter((e) => e.readOnly);
const proposed = next.filter((e) => !e.readOnly).at(-1);
setEvents(proposed ? [...confirmed, asDraft(proposed)] : confirmed);
};
const propose = ({ start }: { start: Date }) => {
const end = new Date(start.getTime() + HOST.minutes * 60_000);
// the same pure check the grid runs — a 30-min call must fit the clicked slot
if (!slotAllowed(createConstraints(constraints), { start, end, allDay: false })) return;
setBooked(null);
setEvents((prev) => [...prev.filter((e) => e.readOnly), draftBooking(start, end)]);
};
const confirm = (form: FormData) => {
if (!draft) return;
const booking = confirmBooking(draft, {
name: String(form.get("name") || "Guest"),
email: String(form.get("email") || "guest@example.com"),
});
setEvents((prev) => [...prev.filter((e) => e.id !== DRAFT_ID), booking]);
setBooked(booking);
};
return (
<div className="grid h-[34rem] grid-rows-[auto_1fr] sm:grid-cols-[16rem_1fr] sm:grid-rows-1">
<aside className="flex flex-col gap-5 border-b p-4 sm:border-r sm:border-b-0">
<Host />
{booked ? (
<div className="flex flex-col gap-2 border-t pt-4">
<p className="font-mono text-xs text-muted-foreground">confirmed</p>
<p className="text-sm font-medium">{booked.title}</p>
<p className="font-mono text-xs text-muted-foreground tabular-nums">{whenLabel(booked)}</p>
<Button variant="outline" size="sm" className="mt-2 self-start" onClick={() => setBooked(null)}>
Book another
</Button>
</div>
) : draft ? (
<form action={confirm} className="flex flex-col gap-3 border-t pt-4">
<p className="font-mono text-xs text-muted-foreground tabular-nums">{whenLabel(draft)}</p>
<Input name="name" placeholder="Your name" aria-label="Your name" size="sm" />
<Input name="email" type="email" placeholder="you@example.com" aria-label="Email" size="sm" />
<div className="flex items-center gap-2">
<Button type="submit" size="sm">Confirm</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEvents((prev) => prev.filter((e) => e.readOnly))}
>
Cancel
</Button>
</div>
</form>
) : (
<p className="border-t pt-4 text-sm text-muted-foreground">
Click a free slot. The hatched time is busy — the grid refuses it. Drag the green block
to adjust.
</p>
)}
</aside>
<Kloq
view={3}
responsive={false}
dayStartHour={9}
dayEndHour={18}
snapMinutes={30}
minEventMinutes={30}
workingHours={HOST.hours}
constraints={constraints}
events={events}
onEventsChange={onEventsChange}
onSlotClick={propose}
components={SLOTS}
persistence={false}
designMode={false}
storage={false}
>
<Kloq.Toolbar>
<Nav />
<span className="font-mono text-xs text-muted-foreground">{HOST.minutes}-min slots</span>
</Kloq.Toolbar>
</Kloq>
</div>
);
}
function Host() {
const isClient = useIsClient();
return (
<div>
<p className="font-mono text-xs text-muted-foreground">book a call with</p>
<p className="mt-1 font-heading text-lg font-semibold tracking-tight">{HOST.name}</p>
<p className="text-sm text-muted-foreground">
{HOST.title} · {HOST.minutes} min
</p>
<p className="mt-2 font-mono text-xs text-muted-foreground">{isClient ? localZone() : " "}</p>
</div>
);
}
function Nav() {
const k = useKloq();
const isClient = useIsClient();
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" aria-label="Earlier" onClick={() => k.step(-1)}>‹</Button>
<span className="min-w-36 text-center font-mono text-xs text-muted-foreground tabular-nums">
{isClient ? formatViewTitle(k.view) : " "}
</span>
<Button variant="ghost" size="icon-xs" aria-label="Later" onClick={() => k.step(1)}>›</Button>
{!k.atToday && (
<Button variant="ghost" size="xs" onClick={k.goToday}>Today</Button>
)}
</div>
);
}
import {
dateOnlyAt,
formatInstant,
formatRange,
minutesOfDate,
newEventId,
parseInstant,
resolveView,
toDateOnlyString,
type Attendee,
type CalEvent,
type KloqConstraints,
} from "kloq";
export const HOST = {
name: "Priya Raman",
email: "priya@lumen.dev",
title: "Intro call",
minutes: 30,
hours: { start: 9, end: 17 },
};
export const DRAFT_ID = "draft";
type Hhmm = `${number}:${number}`;
/** Busy windows keyed by ISO date, each `["HH:mm", "HH:mm")` — the shape an availability endpoint returns. */
export type BusyCalendar = Record<string, [Hhmm, Hhmm][]>;
/** Demo stand-in for that endpoint: three days of busy time, keyed to the visible dates. */
export function busyCalendar(now = new Date()): BusyCalendar {
const days = resolveView(3, 1, now);
return {
[dateOnlyAt(days, 0)]: [["09:00", "10:00"], ["12:00", "13:00"], ["14:30", "15:30"]],
[dateOnlyAt(days, 1)]: [["10:00", "11:30"], ["12:00", "13:00"], ["16:00", "17:00"]],
[dateOnlyAt(days, 2)]: [["09:00", "09:30"], ["12:00", "13:00"], ["13:30", "15:00"]],
};
}
const minuteOf = (t: Hhmm) => {
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
};
/**
* Everything the grid must refuse: the past, time outside the host's hours,
* her busy windows, and any booking already confirmed.
*/
export function hostConstraints(
busy: BusyCalendar,
bookings: CalEvent[],
now = new Date(),
): KloqConstraints {
const confirmed = bookings
.filter((b) => b.readOnly)
.map((b) => ({ start: parseInstant(b.start), end: parseInstant(b.end) }));
return {
minDate: now,
isSlotDisabled(slot) {
if (slot.allDay) return true; // a call is never all-day
const startMin = minutesOfDate(slot.start);
const endMin = startMin + (slot.end.getTime() - slot.start.getTime()) / 60_000;
if (startMin < HOST.hours.start * 60 || endMin > HOST.hours.end * 60) return true;
const windows = busy[toDateOnlyString(slot.start)] ?? [];
if (windows.some(([from, to]) => startMin < minuteOf(to) && endMin > minuteOf(from))) return true;
return confirmed.some(
(w) => slot.start.getTime() < w.end && slot.end.getTime() > w.start,
);
},
};
}
export function draftBooking(start: Date, end: Date): CalEvent {
return {
id: DRAFT_ID,
title: "Your booking",
start: formatInstant(start),
end: formatInstant(end),
color: "emerald",
};
}
/** Anything the grid created or moved becomes the one draft — a visitor books one slot at a time. */
export function asDraft(event: CalEvent): CalEvent {
return { ...event, id: DRAFT_ID, title: "Your booking", color: "emerald" };
}
export function confirmBooking(draft: CalEvent, guest: Attendee): CalEvent {
return {
...draft,
id: newEventId(),
title: `${HOST.title} — ${guest.name}`,
readOnly: true,
attendees: [
{ email: HOST.email, name: HOST.name, status: "accepted" },
{ ...guest, status: "accepted" },
],
};
}
export function whenLabel(event: CalEvent): string {
const start = new Date(parseInstant(event.start));
const end = new Date(parseInstant(event.end));
const day = start.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
return `${day} · ${formatRange(minutesOfDate(start), minutesOfDate(end))}`;
}
How it works
- The host's busy time is a
BusyCalendar— windows keyed by ISO date,"2026-09-02": [["12:00", "13:00"]]— the shape an availability endpoint returns.hostConstraintsfolds it into oneconstraintsobject:minDateblocks the past, andisSlotDisabledrefuses those windows, anything outside 9–17, and every confirmed booking. The grid hatches the refused time and refuses create, move, resize and slot clicks into it — no placeholder "Busy" events anywhere. - The constraints are rebuilt from
busyandeventsin auseMemo, so the moment a booking is confirmed its time joins the hatch for the next visitor. onSlotClickfires for a click on free canvas, already snapped. The example re-checks the full 30-minute call withcreateConstraints+slotAllowed— the same pure helpers the grid runs — because a click near the end of the day proposes an end the clicked slot alone can't vouch for.dayStartHour={9}/dayEndHour={18}clip the grid;workingHourstints the bookable band inside it (Settings).snapMinutes={30}andminEventMinutes={30}make every drag land on a half-hour.events/onEventsChangekeep the array in React state (Controlled state): confirmed bookings arereadOnlyevents, and whatever else the grid hands back — a drag-create, a moved draft — folds into the one greendraft.components={{ EventPanel: () => null }}swaps the built-in editor for nothing; the confirm card on the left is the panel (Slots).view={3}withresponsive={false}keeps three columns at every width, and the custom bar usesuseKloq()(Toolbar & chrome).
Take it further
- Feed
isSlotDisabledfrom a fetch keyed ononRangeChange, so paging to next week loads that week's busy windows — the pattern is on the constraints page. - Draw the grid in the host's zone with
displayTimeZone="Europe/London"and add the visitor's as a gutter column withtimeZones(Time zones). - On confirm,
POSTthe booking and hand the visitor a.icswithdownloadICS(booking)(ICS).