Skip to content
SuperScheduler

HealthcareForma (Fictional business)

Physiotherapy clinic appointments

A patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning.

Time scale
Demo with synthetic data
minutes / hours
Pro capabilities
Minute scaleMove rulesBlocked timeReact render slotsDetail panel
Forma
PhysiotherapyAlba R.Room 1Marc D.Room 2Nora S.GymManual therapyTeo V.Room 3Iris M.Room 4F-2311 First assessmentF-2318 Knee rehabF-2320 Shoulder mobilityF-2324 Lower back careF-2330 Knee rehabF-2333 Progress reviewF-2312 Lower back careF-2315 Knee rehabF-2319 Shoulder mobilityF-2322 First assessmentF-2331 Lower back careF-2335 Progress reviewF-2313 Exercise therapyF-2317 Exercise therapyF-2323 Exercise therapyF-2332 Exercise therapyF-2314 OsteopathyF-2316 OsteopathyF-2321 First assessmentF-2326 Lower back careF-2334 OsteopathyF-2310 Sports massageF-2325 Sports massageF-2327 Sports massageF-2336 Sports massageThursday, October 158:00 AM8:30 AM9:00 AM9:30 AM10:00 AM10:30 AM11:00 AM11:30 AM12:00 PM12:30 PM1:00 PM1:30 PM2:00 PM2:30 PM3:00 PM3:30 PM4:00 PM4:30 PM5:00 PM5:30 PM

Loads SuperScheduler Pro only when you ask for it.

The situation

Forma is a fictional clinic with three physiotherapists, an osteopath and a sports massage therapist. Appointments last 15 to 60 minutes, each practitioner works in one room, and the day is cut by breaks, a case-review meeting and room cleaning between sessions.

When a patient calls to move an appointment, the front desk has seconds to find a slot. A paper grid or a generic calendar lets them book over a break or give an osteopathy session to a physiotherapist. The board has to refuse those slots and say why, out loud for screen reader users too. Only appointment codes and treatment types appear: there is no patient data in this demo.

What you will doYou will try to put an appointment into a cleaning slot, hear why it is refused, move it to the first free slot after the cleaning, and fine-tune its length in 15-minute steps.

Where the library ends and your application begins

SuperScheduler provides

  • Minute scales: 30- and 15-minute cells with snapping, and night hours hidden.
  • Disabled cells that refuse drops, plus per-frame vetoes (allowed, message) while dragging or resizing.
  • A custom drag card, and React content inside each bar through super-scheduler/react-render.
  • Overlap prevention per row and full keyboard moves with announcements.
  • Controlled events through onEventsChange.

This example’s code decides

  • Which disciplines may deliver each treatment.
  • The clinic’s breaks, meeting and room cleaning, and which practitioner each one affects.
  • One sentence per refusal reason, announced in a polite live region when a drop is refused.
  • The detail panel, the booked time per day, the mission and every label on this page.

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: slot sizes, disabled cells built from the block list, the drag card, React bar content and the callbacks where the rules refuse a slot.

scheduler.tsxtsx
/**
 * Forma appointment board: practitioners over two clinic days in 30- or 15-minute slots.
 *
 * Library: minute-scale cells and snapping, disabled cells, drag/resize with per-frame vetoes,
 * a custom drag card, React content inside events (super-scheduler/react-render), keyboard moves.
 * Application: who may deliver which treatment, the clinic's fixed blocks and the sentence that
 * explains every refusal. Those rules live in ./rules and run inside the callbacks below.
 */
import { CheckIcon, Clock3Icon } from 'lucide-react'
import { useMemo, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { MINUTE, civil, formatCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
  TREATMENT_TONES,
  civilOf,
  isAppointmentEvent,
  practitionerOf,
  readAppointmentFields,
  toResources,
  type AppointmentEvent,
} from './adapter'
import { BLOCKS, CLINIC_DAYS, OPENING_HOURS, type Appointment, type Status } from './model'
import { blockAt, slotRefusal, type Refusal } from './rules'
import { ZOOM_IDS, type FormaText, type ZoomId } from './text'

export interface FormaSchedulerProps {
  readonly events: AppointmentEvent[]
  readonly text: FormaText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Slot size on mount. Later changes go through control.zoom so the view animates. */
  readonly initialZoom: ZoomId
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (id: string, refusal: Refusal) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** Slot sizes: snapping follows the cell, so 15-minute cells move appointments by quarters. */
const SLOT_LEVELS: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
  fifteen: {
    scale: 'CellDuration',
    cellDuration: 15,
    cellWidth: 56,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
  thirty: {
    scale: 'CellDuration',
    cellDuration: 30,
    cellWidth: 64,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
}

// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
  id,
  properties: SLOT_LEVELS[id],
}))

/** Every appointment the control holds right now, in the shape the rules read. */
function appointmentsIn(
  control: SuperScheduler.Scheduler,
): Pick<Appointment, 'id' | 'practitioner' | 'start' | 'end'>[] {
  return control.events.list.flatMap((event) => {
    const practitioner = event.resource === undefined ? null : practitionerOf(event.resource)
    if (practitioner === null || !isAppointmentEvent(event)) return []
    return [
      {
        id: String(event.id),
        practitioner: practitioner.id,
        start: civilOf(event.start),
        end: civilOf(event.end),
      },
    ]
  })
}

/** Checks one proposed slot of an appointment against the clinic's rules. */
function refusalFor(
  control: SuperScheduler.Scheduler | null,
  data: object,
  id: string,
  resource: SuperScheduler.ResourceId,
  start: SuperScheduler.Date,
  end: SuperScheduler.Date,
): Refusal | null {
  const fields = readAppointmentFields(data)
  const practitioner = practitionerOf(resource)
  if (control === null || fields === null || practitioner === null) return null
  return slotRefusal(
    { id, treatment: fields.treatment, start: civil(start.ticks), end: civil(end.ticks) },
    practitioner,
    BLOCKS,
    appointmentsIn(control),
  )
}

export function FormaScheduler(props: FormaSchedulerProps) {
  const { events, text, tag, schedulerLocale, initialZoom, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])

  // The drag card shows a refusal while the shadow sits on a forbidden slot; the hook announces
  // the last reason when the pointer is released without a drop, so it is heard, not only seen.
  const refusal = useDropRefusal(onRefused)

  const config = useMemo<SchedulerRenderProps>(() => {
    const time = (date: SuperScheduler.Date) =>
      formatCivil(date.ticks, tag, { hour: 'numeric', minute: '2-digit' })
    const day = (date: SuperScheduler.Date) => formatCivil(date.ticks, tag, { weekday: 'short' })
    const escape = SuperScheduler.Util.escapeHtml

    return {
      locale: schedulerLocale,
      startDate: CLINIC_DAYS.start,
      days: CLINIC_DAYS.count,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The clinic is closed at night: only opening hours are drawn.
      businessBeginsHour: OPENING_HOURS.begin,
      businessEndsHour: OPENING_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: 176,
      rowHeaderWidthAutoFit: false,
      eventHeight: 44,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      allowEventOverlap: false,
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      // A receptionist reads minutes, not nights: the card shows the practitioner, the new
      // times and, on a forbidden slot, the reason set in onEventMoving.
      dragCard: {
        html: (info) => {
          const minutes = Math.round((info.end.ticks - info.start.ticks) / MINUTE)
          const range = `${day(info.start)} ${time(info.start)}–${time(info.end)}`
          const meta = info.refusal ?? text.durationShort.replace('{count}', String(minutes))
          return (
            `<div class="super-scheduler__drag-card-target">${escape(info.rowName ?? '')}</div>` +
            `<div class="super-scheduler__drag-card-range"><span data-edge="moving">${escape(range)}</span></div>` +
            `<div class="super-scheduler__drag-card-meta"><span class="super-scheduler__drag-card-meta-part">${escape(meta)}</span></div>`
          )
        },
      },

      onBeforeTimeHeaderRender: createHeaderHook(tag),

      onBeforeRowHeaderRender: (args) => {
        const practitioner = practitionerOf(args.row.id)
        if (practitioner === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        args.row.html =
          `<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
          `<span class="truncate font-medium">${escape(args.row.name)}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3">${escape(text.rooms[practitioner.room])}</span></span>`
      },

      // Breaks, meetings and cleaning become disabled cells: the library refuses drops on them.
      onBeforeCellRender: (args) => {
        const practitioner = practitionerOf(args.cell.resource)
        if (practitioner === null) return
        const cellStart = civil(args.cell.start.ticks)
        const block = blockAt(BLOCKS, practitioner.id, cellStart, civil(args.cell.end.ticks))
        if (block === null) return
        args.cell.properties.disabled = true
        if (cellStart === block.start) {
          // The engine clips cells with an inline style; the important utility lets the label of a
          // block's first cell run into the next one at the 15-minute scale.
          args.cell.properties.cssClass = 'overflow-visible!'
          args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-1 text-[10.5px] font-semibold text-ink-3">${escape(text.blocks[block.kind])}</span>`
        }
      },

      onBeforeEventRender: (args) => {
        const fields = readAppointmentFields(args.data)
        if (fields === null) return
        const classes = ['ss-ev', toneClass(TREATMENT_TONES[fields.treatment])]
        if (fields.status === 'unconfirmed') classes.push('ss-ev--ghost')
        args.data.cssClass = classes.join(' ')
      },

      // React content inside each bar: code, status icon and treatment, sized to the bar.
      renderEvent: ({ e, width }) => {
        const fields = readAppointmentFields(e.data)
        if (fields === null) return null
        return (
          <AppointmentContent
            code={String(e.id())}
            treatment={text.treatments[fields.treatment]}
            status={fields.status}
            statusLabel={text.statuses[fields.status]}
            width={width}
          />
        )
      },

      onEventMoving: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          controlRef.current,
          args.e.data,
          id,
          args.resource,
          args.start,
          args.end,
        )
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = text.rules[reason]
        }
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          args.control,
          args.e.data,
          id,
          args.newResource,
          args.newStart,
          args.newEnd,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const resource = args.e.resource()
        const reason =
          resource === undefined
            ? null
            : refusalFor(controlRef.current, args.e.data, id, resource, args.start, args.end)
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = text.rules[reason]
        }
      },

      onEventResize: (args) => {
        const id = String(args.e.id())
        const resource = args.e.resource()
        const reason =
          resource === undefined
            ? null
            : refusalFor(args.control, args.e.data, id, resource, args.newStart, args.newEnd)
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()))
      },
      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()))
      },
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    controlRef,
    initialZoom,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    refusal,
    schedulerLocale,
    tag,
    text,
  ])

  return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}

/** What a bar shows, by width: icon, code and treatment when there is room, colour alone when not. */
function AppointmentContent({
  code,
  treatment,
  status,
  statusLabel,
  width,
}: {
  code: string
  treatment: string
  status: Status
  statusLabel: string
  width: number
}) {
  if (width > 0 && width < 40) return null
  const StatusIcon = status === 'confirmed' ? CheckIcon : Clock3Icon
  return (
    <span className="flex min-w-0 flex-col gap-0.5 leading-tight">
      <span className="flex min-w-0 items-center gap-1 font-mono text-[11px] font-semibold tracking-tight">
        {width >= 80 ? <StatusIcon aria-label={statusLabel} className="size-3 shrink-0" /> : null}
        <span className="truncate">{code}</span>
      </span>
      <span className="truncate text-[11.5px] font-medium opacity-80">{treatment}</span>
    </span>
  )
}
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 problemSports club court bookingA padel net snaps mid-morning. Move the clinic, close the court and keep every coach where the rota says.Read the business caseClinics and healthcare