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
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.
/**
* 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>
)
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.