Shift roster
People are the rows, days are the columns. Shifts repeat every week, a form adds one-off shifts, and each person’s bar shows rostered hours against the hours they are contracted for.
The data model
Section titled “The data model”Everything a rota needs fits in two shapes. The people carry their contract and their leave; the shifts are one flat list.
import type { ResourceInput, SchedulerEvent } from "resource-scheduler";
const staff: ResourceInput[] = [ { id: "cy", name: "Cy Ortiz", // The days and hours Cy may be rostered: the contract. businessHours: { daysOfWeek: [1, 2, 3, 4, 5], startHour: 7, endHour: 15 }, // Leave. Shaded, refused by blockUnavailable, and not counted as available. unavailable: [{ start: new Date(2026, 9, 7), end: new Date(2026, 9, 9) }], }, // ...];
const shifts: SchedulerEvent[] = [ { id: "cy-early", title: "Early", resourceId: "cy", // flat events: the row it belongs to startDate: new Date(2026, 8, 21, 7), // the first occurrence, a Monday endDate: new Date(2026, 8, 21, 15), recurrence: { freq: "weekly", byWeekday: [1, 2, 5] }, // Monday, Tuesday, Friday },];In a database this is a people table with contract and leave columns and a shifts table with a person_id. The standing rota is one row with a recurrence; you never store a row per week.
How each rule is expressed
Section titled “How each rule is expressed”| You want | Use |
|---|---|
| The same shift every week | recurrence with freq: "weekly" and byWeekday (recurring events) |
| Contracted days and hours | The person’s businessHours (availability) |
| Leave and sickness | The person’s unavailable ranges |
| Nobody works two shifts at once | eventOverlap={false} |
| No shifts on days off or leave | blockUnavailable |
| Hours rostered against hours contracted | showUtilization (utilization) |
| Add a shift in your own form | onSlotSelect, then add an event |
| Cover someone for a day | onEventDrop, “this event only” |
The parts worth copying
Section titled “The parts worth copying”Covering one shift. Dragging a shift in the rota hands your handler an occurrence of the series. Swapping it for one day means skipping that day in the series and adding a one-off shift for the new person:
onEventDrop={(shift, _from, toId, start, end) => setEvents((all) => { if (!shift.seriesId) { return all.map((e) => (e.id === shift.id ? { ...e, resourceId: toId, startDate: start, endDate: end } : e)); } const skipped = all.map((e) => e.id === shift.seriesId ? { ...e, recurrence: { ...e.recurrence!, exceptions: [...(e.recurrence!.exceptions ?? []), shift.startDate] } } : e ); return [...skipped, { ...shift, id: crypto.randomUUID(), seriesId: undefined, resourceId: toId, startDate: start, endDate: end }]; })}Reading the bar. The bar is booked time over available time for the week on screen. Contracted hours are the available time, leave is cut out of it, and only the part of a shift inside it counts. A person at 100% is fully rostered, at 0% has nothing yet, and a bar above 100% means two shifts overlap (the grid will not let you create that with eventOverlap={false}, but imported data may contain it).
Make it real
Section titled “Make it real”- The shift templates are yours. In the week view the grid only knows days: “Early is 7 to 15” lives in your form, and
blockUnavailablerefuses closed days there. In the day view it also refuses hours outside the contract. - Night shifts that cross midnight are events whose
endDateis on the next day. Utilization counts only the part inside the contract, and onestartHourandendHourcannot describe a night, so give night staffbusinessHours: false(always available). - Enforce rest rules on the server. “At least 11 hours between shifts” or “no more than 48 hours a week” belong in
isValidDropfor fast feedback and in your API for the truth.getUtilizationgives you the weekly hours per person on the server too. - Time zones. Dates are the viewer’s local dates unless you pass
timeZone. For a site in another zone, set it to that site’s zone and shifts keep their wall-clock hours there across clock changes (see Time zones).