LaboratoryBenchlab (Fictional business)
Lab instrument booking
Book an instrument and its calibration comes with it. Clear a session out of a service visit, then stretch your run.
- Time scale
- Demo with synthetic data
- hours / days
- Pro capabilities
- React render slotsBlocked timeMove rulesDetail panel
Loads SuperScheduler Pro only when you ask for it.
The situation
Benchlab is a fictional shared lab: two microscopes, an LC-MS, a spectrophotometer, qPCR cyclers, a sequencer and benches used by four research groups. Most instruments need time before a run: the confocal laser warms up, the LC-MS calibrates for an hour, the sequencer primes its flow cell.
When bookings only record the run, the preparation eats into the previous slot, and a vendor’s service visit lands on a session nobody moved. The board has to treat preparation as part of the booking, keep unavailable time out of reach and say clearly why a request does not fit. The rules here are this lab’s own booking policy, not a compliance system.
What you will doYou will move a session out of a service window, book two hours of LC-MS time with the calibration added for you, and extend that run while the calibration keeps its length.
Where the library ends and your application begins
SuperScheduler provides
- Hour and day zoom levels with nights and weekends hidden.
- Disabled cells that hatch unavailable hours and refuse drops, set from onBeforeCellRender.
- Event areas anchored in time, used to draw the preparation inside each booking.
- Drag-to-select time ranges, drag and resize with overlap prevention, refusal messages on the drag card, undo/redo with controlled events and recorded creations.
- React row headers (renderRowHeader) and booking bars (renderEvent) from super-scheduler/react-render.
This example’s code decides
- How long each instrument prepares, and how a selected run becomes a booking with the preparation in front.
- Which changes are allowed: identical instruments only, never across service or maintenance, runs of 30 minutes or more, preparation fixed at the start.
- The service and maintenance windows, and the bookings they collide with.
- The booking panel, the mission and every label in this page. No regulatory or audit claims.
The code behind this demo
These are the files this page runs, not a simplified copy. They compile against the public SuperScheduler Pro exports.
The integration: zoom levels, disabled cells for unavailable time, the preparation area of each booking, drag-to-book, and the callbacks where the rules refuse a move or a resize.
/**
* Benchlab instrument board: shared instruments and benches over one week, preparation included.
*
* Library: timeline with hidden nights, hour and day zoom levels, tree rows, disabled cells for
* unavailable time, time-anchored event areas for the preparation, drag-to-book range selection,
* drag/resize with overlap prevention, undo history, keyboard, and React content (row headers and
* booking bars) through `super-scheduler/react-render`.
* Application: how long each instrument prepares, where a booking may go, unavailable windows and
* what a new booking needs in front of its run. Those rules live in ./rules and run in the callbacks.
*/
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
GROUP_TONES,
civilOf,
isInstrumentId,
readBookingFields,
toResources,
type BookingEvent,
} from './adapter'
import { INITIAL_VIEW_START, LAB_HOURS, WEEK, type Block, type InstrumentId } from './model'
import { blockAt, instrumentOf, moveProblem, type MoveProblem } from './rules'
import { BookingBar, InstrumentHeader } from './instrument-slots'
import type { BenchlabText, ZoomId } from './text'
import './lab.css'
export interface BenchlabSchedulerProps {
readonly events: BookingEvent[]
readonly blocks: readonly Block[]
/** Bookings that overlap unavailable time. */
readonly collisions: readonly string[]
readonly text: BenchlabText
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
/** Level on mount. Later changes go through control.zoom so the view animates from where it is. */
readonly initialZoom: ZoomId
readonly history: SchedulerHistory
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
/** A run dragged across free time: the application plans the booking around it. */
readonly onRequest: (instrument: InstrumentId, start: string, end: string) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onResized: (id: string) => void
readonly onRefused: (message: string) => void
readonly onZoomLevel: (level: ZoomId) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 56,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
{
id: 'days',
properties: {
scale: 'Day',
cellWidth: 180,
timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'days']
export function BenchlabScheduler(props: BenchlabSchedulerProps) {
const {
events,
blocks,
collisions,
text,
tag,
schedulerLocale,
initialZoom,
history,
controlRef,
} = props
const { onEventsChange, onRequest, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// Header labels follow the active level. An animated zoom renders the new level's headers before
// control.scale reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
// Narrower row headers on phones (no icons, smaller indent), chosen once on mount.
const compact = useMemo(() => window.matchMedia('(max-width: 639px)').matches, [])
const config = useMemo<SchedulerProps>(() => {
const colliding = new Set(collisions)
const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
const refusal = (problem: MoveProblem) =>
problem === 'other-model'
? text.rules.otherModel
: problem === 'unavailable'
? text.rules.unavailable
: text.rules.tooShort
return {
locale: schedulerLocale,
startDate: WEEK.start,
days: WEEK.days,
weekStarts: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The lab is open 07:00–20:00: nights stay hidden on the hour level.
businessBeginsHour: LAB_HOURS.begin,
businessEndsHour: LAB_HOURS.end,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: compact ? 144 : 216,
rowHeaderWidthAutoFit: false,
treeIndent: compact ? 10 : 20,
eventHeight: 30,
rowMarginTop: 3,
rowMarginBottom: 3,
allowEventOverlap: false,
// Selecting free time (pointer drag or keyboard) asks for a booking; plain clicks do not.
timeRangeSelectedHandling: 'Enabled',
dragCard: {
duration: false,
dateFormat: pattern,
movingDateFormat: pattern,
markerFormat: pattern,
},
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
},
// Unavailable time: hour cells inside a window are disabled (hatched, drops refused by the
// library); a day cell that only partly overlaps one is tinted.
onBeforeCellRender: (args) => {
const instrument = args.cell.resource
if (!isInstrumentId(instrument)) return
const start = args.cell.start.value
const end = args.cell.end.value
const block = blockAt(instrument, start, end, blocks)
if (block === undefined) return
if (
parseCivil(block.start) <= parseCivil(start) &&
parseCivil(end) <= parseCivil(block.end)
) {
args.cell.properties.disabled = true
return
}
args.cell.properties.cssClass = 'bl-unavailable'
args.cell.properties.html = `<span class="block truncate px-1.5 pt-1 text-[11px] text-ink-3">${SuperScheduler.Util.escapeHtml(text.blocks[block.kind])}</span>`
},
onBeforeEventRender: (args) => {
const fields = readBookingFields(args.data)
if (fields === null) return
// HTML fallback shown until the React bar (renderEvent) mounts.
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.project)}</span></span>`
const classes = ['ss-ev', toneClass(GROUP_TONES[fields.group])]
if (colliding.has(String(args.data.id)))
classes.push('ss-ev--late', 'outline-2', 'outline-offset-1', 'outline-danger')
args.data.cssClass = classes.join(' ')
// The preparation is drawn by the library as an area anchored in time at the booking start.
args.data.areas =
fields.prepMinutes === 0
? []
: [
{
start: args.data.start,
end: args.data.start.addMinutes(fields.prepMinutes),
top: 0,
bottom: 0,
cssClass: 'bl-prep',
action: 'None',
},
]
},
// Identical instruments only, never across unavailable time; overlap is refused by the library.
onEventMoving: (args) => {
const fields = readBookingFields(args.e.data)
const from = args.e.resource()
if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.resource)) return
const problem = moveProblem(
{ instrument: from, prepMinutes: fields.prepMinutes },
args.resource,
args.start.value,
args.end.value,
blocks,
)
if (problem !== null) {
args.allowed = false
args.message = refusal(problem)
}
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const fields = readBookingFields(args.e.data)
const from = args.e.resource()
if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.newResource)) return
const problem = moveProblem(
{ instrument: from, prepMinutes: fields.prepMinutes },
args.newResource,
args.newStart.value,
args.newEnd.value,
blocks,
)
if (problem !== null) {
args.preventDefault()
onRefused(refusal(problem))
}
},
// The preparation belongs to the instrument: only the end of a booking can be dragged.
onEventResizing: (args) => {
const fields = readBookingFields(args.e.data)
const instrument = args.e.resource()
if (fields === null || !isInstrumentId(instrument)) return
const problem =
args.what === 'start'
? null
: moveProblem(
{ instrument, prepMinutes: fields.prepMinutes },
instrument,
args.start.value,
args.end.value,
blocks,
)
if (args.what === 'start' || problem !== null) {
args.allowed = false
args.message = problem === null ? text.rules.startEdge : refusal(problem)
}
},
onEventResize: (args) => {
const fields = readBookingFields(args.e.data)
const instrument = args.e.resource()
if (fields === null || !isInstrumentId(instrument)) return
const problem = moveProblem(
{ instrument, prepMinutes: fields.prepMinutes },
instrument,
args.newStart.value,
args.newEnd.value,
blocks,
)
if (args.what === 'start' || problem !== null) {
args.preventDefault()
onRefused(problem === null ? text.rules.startEdge : refusal(problem))
}
},
onTimeRangeSelected: (args) => {
args.control.clearSelection()
if (args.origin === 'click' || !isInstrumentId(args.resource)) return
onRequest(args.resource, civilOf(args.start), civilOf(args.end))
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id())),
onEventClick: (args) => onSelect(String(args.e.id())),
onZoom: (args) => {
if (args.phase !== 'end') return
setHeaderHook(() => createHeaderHook(tag))
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
history,
controlRef,
blocks,
collisions,
compact,
onEventsChange,
onMoved,
onRefused,
onRequest,
onResized,
onSelect,
onZoomLevel,
schedulerLocale,
headerHook,
tag,
text,
initialZoom,
])
// React row headers: instruments show how long they prepare before every run.
const renderRowHeader = useMemo<NonNullable<SchedulerProps['renderRowHeader']>>(
() =>
({ row }) => {
const id = row.id
if (!isInstrumentId(id)) return row.name
const instrument = instrumentOf(id)
const minutes = instrument.prep.minutes
return (
<InstrumentHeader
name={text.instruments[id]}
area={compact ? null : instrument.area}
prep={
minutes === 0
? text.noPrep
: format(text.prepChip, {
minutes: plural(text.minutes, minutes, tag),
kind: text.prepKinds[instrument.prep.kind],
})
}
/>
)
},
[text, tag, compact],
)
// React booking bars: project, group and sample count; a warning icon when the time is unavailable.
const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
() =>
({ e, data, width }) => {
const fields = readBookingFields(data)
if (fields === null) return e.text()
return (
<BookingBar
project={fields.project}
group={text.groups[fields.group]}
samples={plural(text.samples, fields.samples, tag)}
unavailable={collisions.includes(String(e.id()))}
compact={width < 176}
/>
)
},
[collisions, text, tag],
)
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return (
<SuperSchedulerComponent
{...config}
resources={resources}
events={events}
renderRowHeader={renderRowHeader}
renderEvent={renderEvent}
/>
)
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.