Skip to content
SuperScheduler

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
Benchlab
MicroscopyConfocal microscopeWidefield microscopeAnalyticsLC-MSUV-Vis spectrophotometerMolecularqPCR cycler 1qPCR cycler 2SequencerBenchesWet bench 1Wet bench 2Biosafety cabinetCB-091CB-088PX-220PX-214CB-088GN-140TL-02CB-091GN-140Wednesday, October 21Thursday, October 227 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM

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.

scheduler.tsxtsx
/**
 * 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}
    />
  )
}
Download the example source (.zip)The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.

Share this scenario

The link opens this example with the current view (time scale and options). It never contains your edits or personal data.

A different business, a related problemTraining room schedulingEnrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view.Read the business caseShared rooms and equipment