Live eventsAurora Live (Fictional business)
Festival stage scheduling
A line check runs into a set’s buffer. Zoom to five minutes, trim it, and see every room and crew the act depends on.
- Time scale
- Demo with synthetic data
- minutes / hours
- Pro capabilities
- DependenciesContinuous zoomMinute scaleDetail panelTheming
Loads SuperScheduler Pro only when you ask for it.
The situation
Aurora Live is a fictional one-day festival with three stages, two green rooms, a press room and four crews. The running order is published weeks ahead; what production still plans on the day are the line checks, squeezed between sets, each with a buffer before the act goes on.
In a spreadsheet five minutes disappear: a check that ends ten minutes before a set looks fine at hourly resolution. The board has to zoom down to the minute, keep each check connected to its set, refuse moves that make no sense (a check on another stage, a published set) and show which rooms and crews an act involves.
What you will doYou will open an act’s running sheet in the side panel, zoom to five-minute cells and trim a line check until its link to the set turns neutral.
Where the library ends and your application begins
SuperScheduler provides
- Intraday timeline with the night hidden (business hours, Saturday included) and zoom levels from hours to 15- and 5-minute cells, animated by control.zoom.
- Grouped rows for stages, rooms and crews, and the row selection API that highlights them.
- Curved finish-to-start links between each line check and its set.
- Drag and resize snapped to the active cell, overlap prevention per stage, refusal messages on the drag card, undo/redo and keyboard moves.
- Theming with CSS custom properties and unlayered rules on the control root (festival.css).
This example’s code decides
- Which blocks move (only line checks), on which stage, and how short a check may be.
- The 20-minute buffer between a check and its set, when its link turns red, and which links are drawn (short buffers and the selected act, or all).
- Which rooms and crews relate to an act, and the side panel that jumps to its check or set.
- The five-minute header labels, the mission and every label in 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: three zoom levels, hidden night hours, the header labels at five minutes, row highlighting through rows.selection, and the callbacks where the rules refuse a move or a resize.
/**
* Aurora Live running order: stages, rooms and crews over one festival day, down to five minutes.
*
* Library: timeline with hidden night, zoom levels from hours to 5-minute cells, tree rows, row
* selection (used to highlight related rows), dependency links, drag/resize with overlap
* prevention, undo history, keyboard, and theming through CSS custom properties (./festival.css).
* Application: which blocks can move, the line-check buffer before each set, the shortest check,
* and which rows relate to an act. Those rules live in ./rules and run inside the callbacks below.
*/
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
SchedulerBeforeTimeHeaderRenderArgs,
SchedulerEventsChangeArgs,
SchedulerProps,
} from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { formatCivil } from '../../scheduler/civil'
import { headerLabel, usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { KIND_TONES, readSlotFields, slotLabel, toResources, type SlotEvent } from './adapter'
import {
FESTIVAL_DAY,
INITIAL_VIEW_START,
OPEN_HOURS,
type ActId,
type RowId,
type Slot,
} from './model'
import { checkTooShort, moveProblem, type MoveProblem } from './rules'
import type { AuroraText, ZoomId } from './text'
import './festival.css'
export interface AuroraSchedulerProps {
readonly events: SlotEvent[]
readonly slots: readonly Slot[]
/** Acts whose line check ends too close to their set. */
readonly shortBuffers: readonly ActId[]
/** Rows that relate to the act in the side panel (highlighted). */
readonly relatedRows: readonly RowId[]
readonly text: AuroraText
/** 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
/** Every check-to-set link, or only those of the selected act and of short buffers. */
readonly showAllLinks: boolean
readonly selectedAct: ActId | null
readonly history: SchedulerHistory
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: (message: string) => void
readonly onZoomLevel: (level: ZoomId) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 80,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
{
id: 'quarter',
properties: {
scale: 'CellDuration',
cellDuration: 15,
cellWidth: 64,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
{
id: 'five',
properties: {
scale: 'CellDuration',
cellDuration: 5,
cellWidth: 26,
timeHeaders: [{ groupBy: 'Hour' }, { groupBy: 'Cell' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'quarter', 'five']
/**
* Time headers: the shared Intl labels, except at five minutes where the hour sits above and the
* minutes (":05") below, so 26 px cells stay readable.
*/
function festivalHeaders(tag: string): (args: SchedulerBeforeTimeHeaderRenderArgs) => void {
const shared = createHeaderHook(tag)
return (args) => {
if (args.control.cellDuration !== 5) {
shared(args)
return
}
const ticks = args.header.start.getTime()
const minutes = String(args.header.start.getMinutes()).padStart(2, '0')
args.header.html =
args.header.level === 0
? SuperScheduler.Util.escapeHtml(headerLabel(ticks, 'hour', tag, 'group'))
: `:${minutes}`
}
}
export function AuroraScheduler(props: AuroraSchedulerProps) {
const {
events,
slots,
shortBuffers,
relatedRows,
selectedAct,
text,
tag,
schedulerLocale,
initialZoom,
showAllLinks,
history,
controlRef,
} = props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// Narrower row headers on phones, chosen once on mount.
const rowHeaderWidth = useMemo(
() => (window.matchMedia('(max-width: 639px)').matches ? 116 : 176),
[],
)
// Header labels follow the active level. An animated zoom renders the new level's headers before
// control.cellDuration reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
const [headerHook, setHeaderHook] = useState(() => festivalHeaders(tag))
// Each act's line check leads to its set: a finish-to-start link, red when the buffer is short.
// Morning checks would cross a whole stage row, so by default only the selected act and the
// short buffers show theirs.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
const short = new Set(shortBuffers)
return slots.flatMap((check) => {
const set =
check.kind === 'check'
? slots.find((slot) => slot.kind === 'set' && slot.act === check.act)
: undefined
if (set === undefined || check.act === undefined) return []
if (!showAllLinks && !short.has(check.act) && check.act !== selectedAct) return []
return [
{
id: `${check.act}-buffer`,
from: check.id,
to: set.id,
type: 'FinishToStart' as const,
...(short.has(check.act) ? { cssClass: 'ss-link--late' } : {}),
},
]
})
}, [slots, shortBuffers, showAllLinks, selectedAct])
const config = useMemo<SchedulerProps>(() => {
const short = new Set<string>(shortBuffers)
const related = new Set<string>(relatedRows)
const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
const clock = (value: string) => formatCivil(value, tag, { hour: 'numeric', minute: '2-digit' })
const refusal = (problem: MoveProblem) =>
problem === 'published'
? text.rules.published
: problem === 'follows'
? text.rules.follows
: text.rules.wrongStage
return {
locale: schedulerLocale,
startDate: FESTIVAL_DAY,
days: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The site opens at noon: the morning stays hidden. A Saturday is a working day here.
businessBeginsHour: OPEN_HOURS.begin,
businessEndsHour: OPEN_HOURS.end,
businessWeekends: true,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
cssClass: 'al-board',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth,
rowHeaderWidthAutoFit: false,
eventHeight: 32,
rowMarginTop: 4,
rowMarginBottom: 4,
allowEventOverlap: false,
linkLayer: 'Below',
timeRangeSelectedHandling: 'Disabled',
// Minute-level work: the drag card and header marker show clock times, not dates.
dragCard: {
duration: false,
dateFormat: pattern,
movingDateFormat: pattern,
markerFormat: pattern,
},
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
},
// The selected act's rows are also tinted across the grid (headers come from rows.selection).
onBeforeCellRender: (args) => {
if (related.has(String(args.cell.resource))) args.cell.properties.cssClass = 'al-related'
},
onBeforeEventRender: (args) => {
const fields = readSlotFields(args.data)
if (fields === null) return
const label = SuperScheduler.Util.escapeHtml(slotLabel(fields, text))
const time =
fields.kind === 'set'
? `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(clock(args.data.start.value))}</span>`
: ''
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${label}</span>${time}</span>`
const classes = ['ss-ev', toneClass(KIND_TONES[fields.kind])]
if (fields.kind === 'set') classes.push('al-set')
if (fields.kind === 'check') classes.push('ss-ev--ghost')
if (fields.kind === 'changeover') classes.push('ss-ev--hatched')
if (fields.kind === 'set' && fields.act !== undefined && short.has(fields.act))
classes.push('ss-ev--late')
args.data.cssClass = classes.join(' ')
},
// Only line checks move, and only on their act's stage; overlap is refused by the library.
onEventMoving: (args) => {
const fields = readSlotFields(args.e.data)
const problem = fields === null ? null : moveProblem(fields, String(args.resource))
if (problem !== null) {
args.allowed = false
args.message = refusal(problem)
}
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const fields = readSlotFields(args.e.data)
const problem = fields === null ? null : moveProblem(fields, String(args.newResource))
if (problem !== null) {
args.preventDefault()
onRefused(refusal(problem))
}
},
onEventResizing: (args) => {
if (checkTooShort(args.start.value, args.end.value)) {
args.allowed = false
args.message = text.rules.tooShort
}
},
onEventResize: (args) => {
if (checkTooShort(args.newStart.value, args.newEnd.value)) {
args.preventDefault()
onRefused(text.rules.tooShort)
}
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id())),
onEventClick: (args) => onSelect(String(args.e.id())),
onZoom: (args) => {
if (args.phase !== 'end') return
setHeaderHook(() => festivalHeaders(tag))
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
history,
controlRef,
shortBuffers,
relatedRows,
rowHeaderWidth,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
schedulerLocale,
headerHook,
tag,
text,
initialZoom,
])
// Highlighted rows are the control's row selection, driven by the act in the side panel.
useEffect(() => {
const control = controlRef.current
if (control === null || control.disposed()) return
control.rows.selection.clear()
for (const id of relatedRows) {
const row = control.rows.find(id)
if (row !== null) control.rows.selection.add(row)
}
}, [controlRef, relatedRows])
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return <SuperSchedulerComponent {...config} resources={resources} events={events} links={links} />
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.