Skip to content
SuperScheduler

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
Court Club
Court 1 · Clay17%Court 2 · Clay18%Court 3 · Hard18%Padel 125%Padel 220%Padel 315%Studio A · Bikes10%Studio B · Mats10%Coach Iris50%Coach Mateo30%Coach Noor59%Line brushingTennis · IrisTennis · Member hireTennis · IrisLine brushingTennis · Member hireTennis · MateoTennis · Member hireTennis · Member hirePickleball · Member hireTennis · MateoPadel · Member hirePadel · NoorPadel · Member hirePadel · Member hirePadel · Member hirePadel · NoorPadel · Member hirePadel · Member hirePadel · Member hireSpin · IrisSpin · NoorYoga · IrisPilates · NoorOn dutyOn dutyOn dutyOn dutyOn dutySaturday, October 177:00 AM7:30 AM8: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 PM6:00 PM

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.

scheduler.tsxtsx
/**
 * 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)}`
}
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 problemPhysiotherapy clinic appointmentsA patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning.Read the business caseVenues and events