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