02examples
Shift scheduler
Five people, one week, as timeline lanes — drag to schedule, drag across rows to reassign a shift to someone else.
Five people, one week, opening to close, each on their own row. A shift is a bar coloured by the person working it; days off are just wider bars covering the whole day, tinted with the same colour. The legend counts rostered hours from the live events — resize Ben's Saturday and his number moves.
liveShift schedulerviewlaneslaneAdaptertimelineWindow
loading
"use client";
import { useState } from "react";
import { EVENT_COLORS, Kloq, type CalEvent } from "kloq";
import { LANES, PEOPLE, STAFF_ADAPTER, hoursByPerson, rosterEvents, rosterWindow } from "./data";
export default function ShiftRosterExample() {
const [events, setEvents] = useState<CalEvent[]>(() => rosterEvents());
const [tlWindow] = useState(() => rosterWindow());
const hours = hoursByPerson(events);
return (
<div className="h-[36rem]">
<Kloq
view="timeline"
lanes={LANES}
laneAdapter={STAFF_ADAPTER}
timelineWindow={tlWindow}
density="compact"
workingHours={{ start: 6, end: 22 }}
events={events}
onEventsChange={setEvents}
persistence={false}
designMode={false}
storage={false}
>
<Kloq.Toolbar>
<span className="font-mono text-xs text-muted-foreground">Fernhill Café · this week, 5 staff</span>
</Kloq.Toolbar>
<ul className="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-b px-4 py-2">
{PEOPLE.map((p) => (
<li key={p.id} className="flex items-center gap-2 font-mono text-xs text-muted-foreground">
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: EVENT_COLORS[p.color].solid }}
/>
<span className="text-foreground">{p.name}</span>
<span>{p.role}</span>
<span className="tabular-nums">{hours[p.id]}h</span>
</li>
))}
</ul>
</Kloq>
</div>
);
}
import {
dateOnlyAfter,
dateOnlyAt,
instantAt,
resolveView,
wallMinutesBetween,
type CalEvent,
type CalEventPatch,
type EventColor,
type Lane,
type LaneAdapter,
} from "kloq";
export interface Person {
id: string;
name: string;
role: string;
color: EventColor;
}
export const PEOPLE: Person[] = [
{ id: "ada", name: "Ada Okoye", role: "Front of house", color: "blue" },
{ id: "ben", name: "Ben Halvorsen", role: "Front of house", color: "violet" },
{ id: "chloe", name: "Chloé Martin", role: "Barista", color: "emerald" },
{ id: "dev", name: "Dev Patel", role: "Barista", color: "amber" },
{ id: "eli", name: "Eli Sandoval", role: "Kitchen", color: "rose" },
];
/** One row per person — the timeline's `lanes`. */
export const LANES: Lane[] = PEOPLE.map((p): Lane => ({ id: p.id, label: p.name, sublabel: p.role }));
type Shift = CalEvent & { personId: string; kind: "shift" | "off" };
// person, day (0 = Monday), from hour, to hour
const SHIFTS: [string, number, number, number][] = [
["ada", 0, 6, 14], ["ada", 1, 6, 14], ["ada", 2, 6, 14], ["ada", 3, 6, 14], ["ada", 4, 6, 14],
["ben", 0, 14, 22], ["ben", 1, 14, 22], ["ben", 2, 14, 22], ["ben", 5, 8, 16], ["ben", 6, 8, 16],
["chloe", 1, 7, 15], ["chloe", 2, 7, 15], ["chloe", 3, 7, 15], ["chloe", 4, 7, 15], ["chloe", 5, 7, 15],
["dev", 0, 12, 20], ["dev", 3, 14, 22], ["dev", 4, 14, 22], ["dev", 5, 14, 22], ["dev", 6, 14, 22],
["eli", 0, 10, 18], ["eli", 1, 10, 18], ["eli", 2, 10, 18], ["eli", 5, 10, 18], ["eli", 6, 10, 18],
];
const DAYS_OFF: [string, number][] = [
["ada", 5], ["ada", 6], ["ben", 3], ["ben", 4], ["chloe", 0], ["chloe", 6],
["dev", 1], ["dev", 2], ["eli", 3], ["eli", 4],
];
const shiftName = (from: number) => (from < 9 ? "Open" : from >= 14 ? "Close" : "Mid");
const first = (p: Person) => p.name.split(" ")[0];
const personOf = (id: string) => PEOPLE.find((p) => p.id === id)!;
/**
* The lane id lives in `personId`, same shape `byResourceId` reads — but
* `assign` here does more than the default: reassigning a shift to someone
* else's row also repaints its colour and retitles it to the new person, so
* a dragged shift reads correctly on its new row instead of carrying its old
* owner's name and colour along for the ride. `assign`'s only contract is
* "the patch that puts this event on `laneId`" — nothing says that patch is
* limited to the field `laneOf` reads.
*/
export const STAFF_ADAPTER: LaneAdapter = {
laneOf: (event) => (event as Shift).personId ?? [],
assign: (event, laneId): CalEventPatch => {
const p = personOf(laneId);
const shift = event as Shift;
const title = shift.kind === "off" ? `${first(p)} off` : `${first(p)} · ${shift.title.split(" · ").at(-1)}`;
return { personId: laneId, color: p.color, title } as CalEventPatch;
},
};
/** A week of shifts and days off, one lane per person — see `LANES` / `STAFF_ADAPTER`. */
export function rosterEvents(now = new Date()): Shift[] {
const w = resolveView("week", 1, now);
const shifts = SHIFTS.map(([id, day, from, to]): Shift => {
const p = personOf(id);
return {
id: `${id}-${day}`,
title: `${first(p)} · ${shiftName(from)}`,
personId: id,
kind: "shift",
start: instantAt(w, day, from * 60),
end: instantAt(w, day, to * 60),
color: p.color,
};
});
const off = DAYS_OFF.map(([id, day]): Shift => {
const p = personOf(id);
return {
id: `${id}-off-${day}`,
title: `${first(p)} off`,
personId: id,
kind: "off",
allDay: true,
start: dateOnlyAt(w, day),
end: dateOnlyAfter(w, day),
color: p.color,
busy: false,
};
});
return [...shifts, ...off];
}
/** The timeline's window: the whole week, hour slots — a controlled span, so it stays this week regardless of ‹ ›. */
export function rosterWindow(now = new Date()) {
const w = resolveView("week", 1, now);
const start = w.days[0];
const end = new Date(start.getTime() + 7 * 24 * 60 * 60_000);
return { start, end, slotMinutes: 60, pxPerSlot: 32 };
}
/** Rostered hours per person, from the live events — resizing a shift moves the number. */
export function hoursByPerson(events: CalEvent[]): Record<string, number> {
const hours = Object.fromEntries(PEOPLE.map((p) => [p.id, 0]));
for (const e of events) {
const id = (e as Shift).personId;
if (id && !e.allDay) hours[id] += wallMinutesBetween(e.start, e.end) / 60;
}
return hours;
}
How it works
view="timeline"withlanes={LANES}— oneLaneper person, rather than the day columns aweekview would give this a calendar-shaped approximation of (Resource timeline).timelineWindowis controlled:start/endpin the window to this calendar week exactly (slotMinutes: 60), so the roster never drifts — there's no toolbar ‹ › here, because a controlled window doesn't move with it (the twotimelineWindowmodes).- The
LaneAdapteris hand-rolled, notbyResourceId(): reassigning a shift to someone else's row doesn't just repointpersonId—assignalso repaints the bar's colour and retitles it to the new person, so a dragged shift reads correctly on arrival instead of carrying its old owner's name along.assign's contract is "the patch that puts this event onlaneId", and nothing limits that patch to the fieldlaneOfreads (a worked custom adapter). - Days off are ordinary events with
allDay: truespanning midnight to midnight (dateOnlyAt/dateOnlyAfter, unchanged from before) — the timeline has no all-day lane to promote them into, so they simply render as a bar the width of the day, same as any other timed booking. workingHours={{ start: 6, end: 22 }}tints outside the café's hours on every day the window covers, anddensity="compact"shrinks the bars to fit five rows without scrolling.events/onEventsChangemake the roster React state (Controlled state);hoursByPersonindata.tssumswallMinutesBetweenover it on every render, which is why the legend follows a resize.
Take it further
- Put each person's row height up with
Lane.heightfor anyone who regularly gets double-covered, the same lever the room booking example uses for its busiest room (the overflow cap). - Add
onCommitto push each change to a rota service and let a rejection spring the shift back (Optimistic sync). - Flag understaffed hours:
findConflictsgives you overlaps, and the gaps are the complement.