SportsCourt Club (Fictional business)
Sports club court booking
A padel net snaps mid-morning. Move the clinic, close the court and keep every coach where the rota says.
- Time scale
- Demo with synthetic data
- minutes / hours
- Pro capabilities
- ThemingContext menuMove rulesBlocked timeContinuous zoom
Loads SuperScheduler Pro only when you ask for it.
The situation
Court Club is a fictional racket and fitness club with three tennis courts, three padel courts, a cycling studio and a mat studio. Members book courts by the half hour, and coaches teach classes on any surface their sport allows, but only while they are on the rota.
At the front desk the weekend changes by the minute: a net breaks, a class runs as Pilates instead of Yoga, a court needs its lines brushed. The board has to refuse a padel class on a clay court, a coach in two places at once and a booking on top of another, and offer the right commands exactly where the receptionist is looking.
What you will doYou will move a coached padel class to a free court, close the broken court for its repair from a context menu, and switch a studio class to another activity, with every refusal explained.
Where the library ends and your application begins
SuperScheduler provides
- Half-hour and hourly grids showing opening hours only, with an animated switch between them.
- Drag and resize with overlap refusal inside each row and a highlight on the booking in the way.
- SuperScheduler.Menu context menus on bookings and on selected time, with submenus, disabled items and Shift+F10.
- The drag card that says why a drop is refused, and keyboard moves with announcements.
- A skin made of --super-scheduler-* tokens on one root class, scoped to this board.
This example’s code decides
- Which surface hosts which activity, and the sentence shown for each refusal.
- Coach checks across rows: on the rota, and not teaching somewhere else at the same time.
- What each menu command does, and which lengths fit before the next booking or closing time.
- Occupancy in the row headers, the detail panel 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: two grid levels, opening hours only, the move and resize checks, and both menus filled when they open from the latest bookings.
/**
* Court Club booking board: courts, studios and the coaches' rota over one weekend.
*
* Library: half-hour and hourly grids, drag/resize, per-row overlap refusal with conflict
* highlight, SuperScheduler.Menu context menus, keyboard (Shift+F10 opens the menus) and a skin
* made of CSS tokens scoped to this board (./skin.css).
* Application: which surface hosts which activity, whether the coach is on duty and free, and what
* each menu command does to the bookings. Those rules live in ./rules.
*/
import { useEffect, useLayoutEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { plural } from '~/i18n/format'
import { MINUTE, formatCivil, minutesBetween, type CivilDateTime } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
civilOf,
isVenueId,
itemLabel,
itemTone,
readClubFields,
rowMetrics,
toResources,
type ClubEvent,
} from './adapter'
import {
OPENING_HOURS,
WEEKEND,
type Activity,
type ClubItem,
type RowId,
type VenueId,
} from './model'
import {
ACTIVITIES_BY_VENUE,
LENGTHS,
canHost,
coachIssue,
lengthIssue,
venueConflicts,
venueKind,
type Length,
} from './rules'
import type { CourtClubText, ZoomId } from './text'
import './skin.css'
/** Menu commands. The demo applies them to its React state, the single source of truth. */
export interface ClubActions {
readonly switchActivity: (id: string, activity: Activity) => void
readonly setLength: (id: string, minutes: Length) => void
readonly cancel: (id: string) => void
readonly reopen: (id: string) => void
readonly block: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
readonly hire: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
}
export interface CourtClubSchedulerProps {
readonly events: ClubEvent[]
/** The same data read back as the club's model, for the rules. */
readonly items: readonly ClubItem[]
readonly text: CourtClubText
/** 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 controlRef: RefObject<SuperScheduler.Scheduler | null>
/** Receives the booking menu, so the detail panel's "Options" button can open it too. */
readonly menuRef: RefObject<SuperScheduler.Menu | null>
readonly actions: ClubActions
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onResized: (id: string) => void
/** A refusal while dragging (null once the target is valid again) or after a keyboard move. */
readonly onRefused: (message: string | null) => void
readonly onZoomLevel: (level: ZoomId) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'half-hours',
properties: {
scale: 'CellDuration',
cellDuration: 30,
cellWidth: 56,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 64,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['half-hours', 'hours']
/** Rows that start a new family of venues get a court line above them. */
const SECTION_STARTS: ReadonlySet<SuperScheduler.ResourceId> = new Set<RowId>([
'padel-1',
'studio-a',
'coach-iris',
])
/** What the menus need when they open; refreshed by the board after every render. */
interface MenuContext {
readonly items: CourtClubSchedulerProps['items']
readonly text: CourtClubSchedulerProps['text']
readonly actions: ClubActions
readonly tag: string
readonly clearSelection: () => void
}
/**
* Both menus are created once, outside render. What they offer is decided when they open, from
* the context the board hands over after each render.
*/
function createClubMenus() {
let context: MenuContext | null = null
const selection = new SuperScheduler.Menu({
theme: 'cc_menu',
showMenuTitle: true,
onShow: (args) => {
const source = args.source
if (
!(source instanceof SuperScheduler.Selection) ||
source.resource === undefined ||
!isVenueId(source.resource)
) {
args.preventDefault()
return
}
if (context === null) {
args.preventDefault()
return
}
const { items: current, text: t, actions: run, tag: lang } = context
const venue = source.resource
const start = civilOf(source.start)
const end = civilOf(source.end)
const free = venueConflicts(venue, { start, end }, current).length === 0
const courts = venueKind(venue) !== 'bikes' && venueKind(venue) !== 'mats'
args.menu.menuTitle = `${t.rows[venue]} · ${timeRange(start, end, lang)}`
args.menu.items = [
{ text: t.menu.block, disabled: !free, onClick: () => run.block(venue, start, end) },
{
text: t.menu.hire,
disabled: !free,
hidden: !courts,
onClick: () => run.hire(venue, start, end),
},
]
},
// A held selection would linger under the next booking: clear it whatever was chosen.
onHide: () => context?.clearSelection(),
})
const booking = new SuperScheduler.Menu({
theme: 'cc_menu',
showMenuTitle: true,
onShow: (args) => {
const source = args.source
const fields = source instanceof SuperScheduler.Event ? readClubFields(source.data) : null
// The rota has no commands: no menu for shifts.
if (!(source instanceof SuperScheduler.Event) || fields === null || fields.kind === 'shift') {
args.preventDefault()
return
}
if (context === null) {
args.preventDefault()
return
}
const { items: current, text: t, actions: run, tag: lang } = context
const id = String(source.id())
const item = current.find((candidate) => candidate.id === id)
if (item === undefined || item.kind === 'shift') {
args.preventDefault()
return
}
const { title, detail } = itemLabel(fields, t)
args.menu.menuTitle = detail === '' ? title : `${title} · ${detail}`
if (item.kind === 'block') {
args.menu.items = [{ text: t.menu.endBlock, onClick: () => run.reopen(id) }]
return
}
const choices: readonly Activity[] = ACTIVITIES_BY_VENUE[venueKind(item.venue)]
const length = minutesBetween(item.start, item.end)
args.menu.items = [
{
text: t.menu.activity,
disabled: choices.length < 2,
items: choices.map((activity) => ({
text: t.activities[activity],
disabled: activity === item.activity,
...(activity === item.activity ? { cssClass: 'cc-menu__current' } : {}),
onClick: () => run.switchActivity(id, activity),
})),
},
{
text: t.menu.length,
// Each length is checked against the venue, the coach and closing time before it is offered.
items: LENGTHS.map((minutes) => {
const issue = minutes === length ? null : lengthIssue(item, minutes, current)
const label = SuperScheduler.Util.escapeHtml(plural(t.minutes, minutes, lang))
const why =
issue === null
? ''
: `<span class="cc-menu__why">${SuperScheduler.Util.escapeHtml(t.menuReasons[issue])}</span>`
return {
html: `<span>${label}</span>${why}`,
disabled: minutes === length || issue !== null,
...(minutes === length ? { cssClass: 'cc-menu__current' } : {}),
onClick: () => run.setLength(id, minutes),
}
}),
},
{ text: '-' },
{ text: t.menu.cancel, cssClass: 'cc-menu__danger', onClick: () => run.cancel(id) },
]
},
})
return {
selection,
booking,
setContext: (next: MenuContext) => {
context = next
},
}
}
export function CourtClubScheduler(props: CourtClubSchedulerProps) {
const { events, items, text, tag, schedulerLocale, initialZoom, controlRef, menuRef, actions } =
props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
const [menus] = useState(createClubMenus)
useLayoutEffect(() => {
menus.setContext({
items,
text,
actions,
tag,
clearSelection: () => controlRef.current?.clearSelection(),
})
}, [menus, items, text, actions, tag, controlRef])
// Hand the booking menu to the demo; close any open menu when the board goes away.
useEffect(() => {
menuRef.current = menus.booking
return () => {
menuRef.current = null
menus.booking.hide()
menus.selection.hide()
}
}, [menus, menuRef])
// Live metrics for the row headers: booked share of each venue, teaching share of each coach.
const metrics = useMemo(() => rowMetrics(items, text), [items, text])
// Header labels follow each cell's span, so they stay right during the animated 30/60 switch.
const headerHook = useMemo(
() =>
createSpanHeaderHook(tag, (span, level) =>
level === 'group' ? 'day' : span <= 30 * MINUTE ? 'half-hour' : 'hour',
),
[tag],
)
const config = useMemo<SchedulerProps>(() => {
const clock = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
return {
locale: schedulerLocale,
startDate: WEEKEND.start,
days: WEEKEND.days,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The club is closed at night: only opening hours are on the board. Weekends are business days.
businessBeginsHour: OPENING_HOURS.begin,
businessEndsHour: OPENING_HOURS.end,
businessWeekends: true,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
rowHeaderWidth: 196,
rowHeaderWidthAutoFit: false,
eventHeight: 30,
rowMarginTop: 4,
rowMarginBottom: 4,
// Theming: one class on the control root carries the club's tokens (./skin.css).
cssClass: 'cc-skin',
allowEventOverlap: false,
conflictHighlight: true,
dragCard: {
duration: false,
dateFormat: clock,
movingDateFormat: clock,
markerFormat: clock,
},
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
contextMenu: menus.booking,
contextMenuSelection: menus.selection,
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
const id = String(args.row.id)
const name = SuperScheduler.Util.escapeHtml(args.row.name)
const metric = SuperScheduler.Util.escapeHtml(metrics.get(id) ?? '')
const label = SuperScheduler.Util.escapeHtml(text.occupancyLabel)
args.row.html = `<span class="cc-rh"><span class="cc-rh__name">${name}</span><span class="cc-rh__meta" title="${label}">${metric}</span></span>`
const classes = id.startsWith('coach-') ? ['cc-rh--coach'] : []
if (SECTION_STARTS.has(args.row.id)) classes.push('cc-rh--section')
args.row.cssClass = classes.join(' ')
},
onBeforeEventRender: (args) => {
const fields = readClubFields(args.data)
if (fields === null) return
const { title, detail } = itemLabel(fields, text)
const meta =
detail === ''
? ''
: `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(detail)}</span>`
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(title)}</span>${meta}</span>`
const classes = ['ss-ev', toneClass(itemTone(fields)), 'cc-ev', `cc-ev--${fields.kind}`]
if (fields.kind === 'booking' && fields.format === 'class') classes.push('cc-ev--class')
if (fields.kind === 'block') classes.push('ss-ev--hatched')
if (fields.kind === 'shift') classes.push('ss-ev--ghost')
args.data.cssClass = classes.join(' ')
},
// Surface and coach rules run on every shadow change; overlap inside a row is the library's.
onEventMoving: (args) => {
const refusal = moveRefusal(
args.e.data,
args.resource,
civilOf(args.start),
civilOf(args.end),
items,
text,
)
if (refusal !== null) {
args.allowed = false
args.message = refusal
} else if (args.conflicts.length > 0) {
args.message = text.rules.occupied
}
onRefused(refusal ?? (args.conflicts.length > 0 ? text.rules.occupied : null))
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const refusal = moveRefusal(
args.e.data,
args.newResource,
civilOf(args.newStart),
civilOf(args.newEnd),
items,
text,
)
if (refusal !== null) {
args.preventDefault()
onRefused(refusal)
}
},
onEventResizing: (args) => {
const refusal = moveRefusal(
args.e.data,
args.e.resource(),
civilOf(args.start),
civilOf(args.end),
items,
text,
)
if (refusal !== null) {
args.allowed = false
args.message = refusal
}
onRefused(refusal)
},
onEventResize: (args) => {
const refusal = moveRefusal(
args.e.data,
args.e.resource(),
civilOf(args.newStart),
civilOf(args.newEnd),
items,
text,
)
if (refusal !== null) {
args.preventDefault()
onRefused(refusal)
}
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id())),
onEventClick: (args) => onSelect(String(args.e.id())),
// Coach rows show the rota: nothing is booked on them.
onTimeRangeSelecting: (args) => {
if (!isVenueId(args.resource)) args.allowed = false
},
// Dragging across free time offers the slot's commands straight away.
onTimeRangeSelected: (args) => {
if (args.origin !== 'drag' || !isVenueId(args.resource)) return
menus.selection.show(new SuperScheduler.Selection(args.start, args.end, args.resource), {
owner: args.control,
})
},
onZoom: (args) => {
if (args.phase !== 'end') return
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
menus,
controlRef,
headerHook,
initialZoom,
items,
metrics,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
schedulerLocale,
tag,
text,
])
return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
/**
* Why the club refuses `data` at `resource` from `start` to `end`, or null. Blocks and the rota are
* fixed; a booking needs a surface for its activity and, for a class, a coach on duty and free.
*/
function moveRefusal(
data: object,
resource: SuperScheduler.ResourceId,
start: CivilDateTime,
end: CivilDateTime,
items: readonly ClubItem[],
text: CourtClubText,
): string | null {
const fields = readClubFields(data)
if (fields === null) return null
if (fields.kind !== 'booking') return text.rules.fixed
if (!isVenueId(resource)) return text.rules.coachRow
if (!canHost(resource, fields.activity)) return text.rules.wrongVenue[fields.activity]
if (fields.coach === undefined) return null
const id = 'id' in data ? String(data.id) : undefined
const issue = coachIssue(fields.coach, { ...(id === undefined ? {} : { id }), start, end }, items)
if (issue === 'off-duty') return text.rules.offDuty
if (issue === 'double-booked') return text.rules.doubleBooked
return null
}
/** "11:30 AM – 1:00 PM" / "11:30 – 13:00" in the page's language. */
export function timeRange(start: string, end: string, tag: string): string {
const options: Intl.DateTimeFormatOptions = usesTwelveHours(tag)
? { hour: 'numeric', minute: '2-digit' }
: { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }
return `${formatCivil(start, tag, options)} – ${formatCivil(end, tag, options)}`
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.