Skip to content
SuperScheduler

TrainingMorrow (Fictional business)

Training room scheduling

Enrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view.

Time scale
Demo with synthetic data
hours / weeks
Pro capabilities
FiltersRow columnsMulti-selectionSaved views
Morrow
ClassroomsAtlas24 · 32 hBirch16 · 16 hCedar12 · 12 hDelta30 · 12 hWorkshopsForge10 · 32 hLab14 · 24 hInstructorsInes40 hOwen32 hPriya24 hTomas32 hNegotiationLeadershipNegotiationLeadershipOnboardingFirst aidFirst aidSpreadsheetsPythonPythonData literacyData literacyNegotiationWeldingForklift safetyOn leaveOff-siteTuesday, November 10Wednesday, November 11Thursday, November 12Friday, November 13Monday, November 168 AM12 PM8 AM12 PM8 AM12 PM8 AM12 PM8 AM12 PM

Loads SuperScheduler Pro only when you ask for it.

The situation

Morrow is a fictional corporate training centre with four classrooms, two workshops and four instructors. Courses run in half-day blocks over two weeks; each sitting needs a room with the right equipment and enough seats, and an instructor who is not teaching elsewhere or away.

When late enrolments push a course past its room’s capacity, the coordinators need a room that is free at every sitting, suits the course and keeps the sittings together. Checking that by eye across ten days is slow and easy to get wrong: the board has to answer it from a selection, and keep each coordinator’s usual layout one click away.

What you will doYou will select both sittings of a course, see the rooms and instructors that share availability with them, move them together into the room that fits, and save the view to reopen it later.

Where the library ends and your application begins

SuperScheduler provides

  • Grouped rows with three row-header columns: name, seats and booked hours.
  • Click and Ctrl/⌘+click multi-selection kept by the control (multiselect.events()), and drag with overlap refusal.
  • Row filtering through control.rows.filter() and onRowFilter, and cell classes through onBeforeCellRender.
  • Hour and half-day grids with nights and weekends hidden, keyboard navigation and announcements.
  • getViewState and applyViewState from super-scheduler/views: zoom, scroll and collapsed groups as JSON.

This example’s code decides

  • Example rules between resources: equipment, seats and instructor availability for each session.
  • Shared availability: the rooms and instructors free and suitable for every selected session.
  • Moving the other selected sessions with the dragged one: the app shifts them with control.events.update() after the drop.
  • The seat filter, the “Free for selection” filter, and the instructors’ teaching time derived from the sessions.
  • Where views are stored (here, in memory for the visit), their names, 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: row-header columns, multi-selection, the move checks and the companions that follow a dragged session, the availability highlight in onBeforeCellRender and the filter handed to rows.filter().

scheduler.tsxtsx
/**
 * Morrow room board: classrooms, workshops and instructors over two teaching weeks.
 *
 * Library: grouped rows with row-header columns, hour and half-day grids with nights and weekends
 * hidden, multi-selection (Ctrl/⌘+click), row filtering through onRowFilter, cell styling through
 * onBeforeCellRender, keyboard and announcements.
 * Application (example logic): which room suits which course, when an instructor is free, and
 * which rooms and instructors share availability with the selected sessions, and moving the other
 * selected sessions along with a dragged one. See ./rules.
 */
import { useEffect, useMemo, useSyncExternalStore, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { HOUR, parseCivil, type CivilDateTime } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
  COURSE_TONES,
  civilOf,
  hoursLabel,
  isInstructorId,
  isRoomId,
  readCentreFields,
  toResources,
  type CentreEvent,
} from './adapter'
import {
  COURSE_KIT,
  INITIAL_VIEW_START,
  PERIOD,
  TEACHING_HOURS,
  type CentreItem,
  type RoomId,
  type Session,
} from './model'
import {
  companionLandings,
  instructorIssue,
  roomFree,
  roomIssue,
  roomOf,
  sessionIssues,
  sharedAvailability,
  withinTeachingHours,
} from './rules'
import type { MorrowText, SeatFilter, ZoomId } from './text'
import type { MorrowFilters } from './views'
import './morrow.css'

/** The toolbar's filters, handed to `control.rows.filter()` and read back in onRowFilter. */
export class RowFilter {
  constructor(
    readonly minSeats: number,
    /** Rows to keep (shared availability plus the rows of the selection), or null for all. */
    readonly only: ReadonlySet<string> | null,
  ) {}
}

export interface MorrowSchedulerProps {
  readonly events: CentreEvent[]
  /** The same data read back as the centre's model, for the rules. */
  readonly items: readonly CentreItem[]
  /** Ids of the selected sessions (mirrors the control's multiselect). */
  readonly selected: readonly string[]
  readonly filters: MorrowFilters
  readonly text: MorrowText
  /** 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 or saved views. */
  readonly initialZoom: ZoomId
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelectionChange: (ids: readonly string[]) => void
  readonly onMoved: (ids: readonly 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: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 48,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  // Morning and afternoon blocks: 8:00-12:00 and 12:00-16:00.
  {
    id: 'week',
    properties: {
      scale: 'CellDuration',
      cellDuration: 240,
      cellWidth: 120,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week']

const MIN_SEATS = { all: 0, '16': 16, '24': 24 } as const satisfies Record<SeatFilter, number>

export function MorrowScheduler(props: MorrowSchedulerProps) {
  const { events, items, selected, filters, text, tag, schedulerLocale, initialZoom, controlRef } =
    props
  const { onEventsChange, onSelectionChange, onMoved, onRefused, onZoomLevel } = props

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

  // Shared availability of the selection, and the time spans the grid highlights for it.
  const selection = useMemo(
    () =>
      items.filter(
        (item): item is Session => item.kind === 'session' && selected.includes(item.id),
      ),
    [items, selected],
  )
  const shared = useMemo(() => sharedAvailability(selection, items), [selection, items])
  const fitRows = useMemo(() => new Set<string>([...shared.rooms, ...shared.instructors]), [shared])
  const selectedSpans = useMemo(
    () => selection.map((session) => [parseCivil(session.start), parseCivil(session.end)] as const),
    [selection],
  )
  // Instructor rows show when each instructor teaches, derived from the sessions on room rows.
  const teaching = useMemo(() => {
    const spans = new Map<string, (readonly [number, number])[]>()
    for (const item of items) {
      if (item.kind !== 'session') continue
      spans.set(item.instructor, [
        ...(spans.get(item.instructor) ?? []),
        [parseCivil(item.start), parseCivil(item.end)] as const,
      ])
    }
    return spans
  }, [items])

  const compact = useNarrowScreen()

  const headerHook = useMemo(
    () =>
      createSpanHeaderHook(tag, (span, level) =>
        level === 'group' ? 'day' : span >= 4 * HOUR ? 'hours-4' : 'hour',
      ),
    [tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const sessions = new Map(
      items.flatMap((item) => (item.kind === 'session' ? [[item.id, item] as const] : [])),
    )
    // The selected sessions that follow a dragged one (only when the dragged one is selected too).
    const companionsOf = (id: string, room: RoomId, start: string) => {
      const dragged = sessions.get(id)
      return dragged !== undefined && selection.some((session) => session.id === id)
        ? companionLandings(dragged, room, start, selection)
        : []
    }
    // Why the centre refuses this landing, for the dragged session and each companion, or null.
    const refusal = (
      data: object,
      resource: SuperScheduler.ResourceId,
      start: CivilDateTime,
      end: CivilDateTime,
    ): string | null => {
      const fields = readCentreFields(data)
      if (fields === null) return null
      if (fields.kind === 'away') return text.rules.fixed
      if (!isRoomId(resource)) return text.rules.notRoom
      const id = 'id' in data ? String(data.id) : ''
      const companions = companionsOf(id, resource, start)
      const moving = new Set([id, ...companions.map((landing) => landing.session.id)])
      const landings = [
        { ...fields, room: resource, start, end, companion: false },
        ...companions.map((landing) => ({ ...landing.session, ...landing, companion: true })),
      ]
      for (const landing of landings) {
        if (!withinTeachingHours(landing)) return text.rules.hours
        const room = roomIssue(landing.course, landing.attendees, landing.room)
        const kit = COURSE_KIT[landing.course]
        if (room === 'kit' && kit !== null) return text.rules.kit[kit]
        if (room === 'seats') return text.rules.seats
        // The library checks overlaps for the dragged session only; companions are checked here.
        if (landing.companion && !roomFree(landing.room, landing, items, moving))
          return text.rules.roomTaken
        const instructor = instructorIssue(landing.instructor, landing, items, moving)
        if (instructor !== null) return text.rules[instructor]
      }
      return null
    }

    return {
      locale: schedulerLocale,
      startDate: PERIOD.start,
      days: PERIOD.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Courses run 8:00 to 16:00 on weekdays: nights and weekends are hidden.
      businessBeginsHour: TEACHING_HOURS.begin,
      businessEndsHour: TEACHING_HOURS.end,
      showNonBusiness: false,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      // On a phone only the name column stays; seats and hours are in the details panel.
      rowHeaderColumns: [
        { title: text.columns.resource, width: 124 },
        { title: text.columns.seats, width: 72, hidden: compact },
        { title: text.columns.booked, width: 72, hidden: compact },
      ],
      rowHeaderWidthAutoFit: false,
      eventHeight: 26,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      useEventBoxes: 'Never',
      allowEventOverlap: false,
      // Click selects; Ctrl/⌘+click adds to the selection (the control keeps it in multiselect).
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      dragCard: { duration: false },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        const id = args.row.id
        if (!isRoomId(id) && !isInstructorId(id)) return
        const [name, , booked] = args.row.columns
        const label = SuperScheduler.Util.escapeHtml(
          isRoomId(id) ? text.rooms[id] : text.instructors[id],
        )
        const badge = fitRows.has(id)
          ? `<span class="mr-badge">${SuperScheduler.Util.escapeHtml(text.freeBadge)}</span>`
          : ''
        if (name !== undefined)
          name.html = `<span class="mr-rh"><span class="mr-rh__name">${label}</span>${badge}</span>`
        if (booked !== undefined)
          booked.html = `<span class="mr-num">${SuperScheduler.Util.escapeHtml(hoursLabel(id, items, text, tag))}</span>`
        args.row.cssClass = fitRows.has(id) ? 'mr-rh--fit' : ''
      },

      onBeforeCellRender: (args) => {
        const { cell } = args
        if (cell.isParent) return
        const from = cell.start.getTime()
        const to = cell.end.getTime()
        const hit = (spans: readonly (readonly [number, number])[] | undefined) =>
          spans?.some(([start, end]) => start < to && from < end) === true
        const classes: string[] = []
        if (hit(teaching.get(String(cell.resource)))) classes.push('mr-cell--teaching')
        if (fitRows.has(String(cell.resource)) && hit(selectedSpans)) classes.push('mr-cell--fit')
        cell.properties.cssClass = classes.join(' ')
      },

      onBeforeEventRender: (args) => {
        const fields = readCentreFields(args.data)
        if (fields === null) return
        if (fields.kind === 'away') {
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(text.away[fields.reason])}</span></span>`
          args.data.cssClass = `ss-ev ${toneClass('blocked')} ss-ev--hatched`
          return
        }
        const title = SuperScheduler.Util.escapeHtml(text.courses[fields.course])
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${fields.attendees}</span></span>`
        const classes = ['ss-ev', 'mr-ev', toneClass(COURSE_TONES[fields.course])]
        if (issues.has(String(args.data.id))) classes.push('mr-ev--issue')
        // The control tracks the selection; the app draws it, since several sessions can be selected.
        if (selected.includes(String(args.data.id))) classes.push('mr-ev--selected')
        args.data.cssClass = classes.join(' ')
      },

      // Room suitability, instructor availability and teaching hours for every session that moves.
      onEventMoving: (args) => {
        const message = refusal(args.e.data, args.resource, civilOf(args.start), civilOf(args.end))
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        onRefused(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const message = refusal(
          args.e.data,
          args.newResource,
          civilOf(args.newStart),
          civilOf(args.newEnd),
        )
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
        }
      },

      // The library committed the dragged session; the other selected ones follow it through the
      // control's API, so one onEventsChange hands all of them back to React state.
      onEventMoved: (args) => {
        const id = String(args.e.id())
        const moved = [id]
        if (isRoomId(args.newResource)) {
          for (const landing of companionsOf(id, args.newResource, civilOf(args.newStart))) {
            const companion = args.control.events.find(landing.session.id)
            if (companion === null) continue
            args.control.events.update({
              ...companion.data,
              resource: landing.room,
              start: landing.start,
              end: landing.end,
            })
            moved.push(landing.session.id)
          }
        }
        onMoved(moved)
      },

      onEventSelected: () => {
        const control = controlRef.current
        if (control !== null)
          onSelectionChange(control.multiselect.events().map((event) => String(event.id())))
      },

      onRowFilter: (args) => {
        const filter = args.filterParam
        if (!(filter instanceof RowFilter)) return
        const id = args.row.id
        if (isRoomId(id))
          args.visible =
            roomOf(id).seats >= filter.minSeats && (filter.only === null || filter.only.has(id))
        else if (isInstructorId(id)) args.visible = filter.only === null || filter.only.has(id)
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    compact,
    controlRef,
    fitRows,
    headerHook,
    initialZoom,
    issues,
    items,
    onEventsChange,
    onMoved,
    onRefused,
    onSelectionChange,
    onZoomLevel,
    schedulerLocale,
    selected,
    selectedSpans,
    selection,
    tag,
    teaching,
    text,
  ])

  // Filters go through the library's row filtering; the rows of the selection always stay.
  const rowFilter = useMemo(() => {
    const keep =
      filters.freeOnly && selection.length > 0
        ? new Set([
            ...fitRows,
            ...selection.flatMap((session) => [session.room, session.instructor]),
          ])
        : null
    return new RowFilter(MIN_SEATS[filters.seats], keep)
  }, [filters, selection, fitRows])
  useEffect(() => {
    controlRef.current?.rows.filter(rowFilter)
  }, [controlRef, rowFilter])

  // Imperative and once: the board opens on Tuesday, the first Data literacy sitting.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

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

const NARROW = '(max-width: 639px)'

/** True on phone-sized screens (the stage is then too narrow for three header columns). */
function useNarrowScreen(): boolean {
  return useSyncExternalStore(
    (notify) => {
      const query = window.matchMedia(NARROW)
      query.addEventListener('change', notify)
      return () => query.removeEventListener('change', notify)
    },
    () => window.matchMedia(NARROW).matches,
    () => false,
  )
}
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 problemLab instrument bookingBook an instrument and its calibration comes with it. Clear a session out of a service visit, then stretch your run.Read the business caseShared rooms and equipment