Saltar al contenido
SuperScheduler

Eventos en directoAurora Live (Negocio ficticio)

Planificación de escenarios de un festival

Una prueba de sonido invade el margen de una actuación. Amplía a cinco minutos, recórtala y mira qué salas y equipos dependen del artista.

Escala temporal
Demo con datos sintéticos
minutos / horas
Capacidades Pro
DependenciasZoom continuoEscala por minutosPanel de detalleTemas
Aurora Live
EscenariosEscenario AuroraCarpaClubSalasCamerino ACamerino BSala de prensaEquiposSonido ASonido BIluminaciónTécnicos de escenarioLumen ArcPrueba de sonido · Glass OrchardGlass OrchardNova TideNorthern HumPrueba de sonido · Saltwater ChoirSaltwater ChoirPrueba de sonido · Velvet StaticVelvet StaticPrueba de sonido · Kilo BloomGlass OrchardNova TideNorthern HumSaltwater ChoirVelvet StaticOra LuxSaltwater ChoirLumen ArcNova TideTurno de díaTurno de nocheTurno de díaTurno de nocheTurno de showCambio de escenarioCambio de escenariosábado, 24 de octubre16:3016:4517:0017:1517:3017:4518:0018:1518:3018:4519:0019:1519:3019:4520:0020:1520:3020:4521:0021:15

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Aurora Live es un festival ficticio de un día con tres escenarios, dos camerinos, una sala de prensa y cuatro equipos técnicos. El orden de actuaciones se publica semanas antes; lo que producción sigue planificando el mismo día son las pruebas de sonido, encajadas entre actuaciones y con un margen antes de que el grupo salga al escenario.

En una hoja de cálculo cinco minutos desaparecen: una prueba que acaba diez minutos antes de una actuación parece correcta con resolución horaria. El tablero tiene que ampliar hasta el minuto, mantener cada prueba unida a su actuación, rechazar los movimientos sin sentido (una prueba en otro escenario, una actuación ya publicada) y mostrar qué salas y equipos implica cada artista.

Qué vas a hacerAbrirás la hoja de un artista en el panel lateral, ampliarás a celdas de cinco minutos y recortarás una prueba de sonido hasta que su vínculo con la actuación deje de estar en rojo.

Dónde termina la librería y empieza tu aplicación

SuperScheduler aporta

  • Línea de tiempo intradía con la noche oculta (horario laborable, sábado incluido) y niveles de zoom de horas a celdas de 15 y 5 minutos, animados con control.zoom.
  • Filas agrupadas de escenarios, salas y equipos, y la API de selección de filas que las resalta.
  • Vínculos fin-inicio curvos entre cada prueba de sonido y su actuación.
  • Arrastre y redimensionado ajustados a la celda activa, sin solapes por escenario, mensajes de rechazo en la tarjeta de arrastre, deshacer/rehacer y movimiento con teclado.
  • Personalización visual con propiedades CSS y reglas sin capa sobre la raíz del control (festival.css).

El código de este ejemplo decide

  • Qué bloques se mueven (solo las pruebas de sonido), en qué escenario y cuánto pueden acortarse.
  • El margen de 20 minutos entre una prueba y su actuación, cuándo su vínculo se vuelve rojo y qué vínculos se dibujan (márgenes cortos y el artista seleccionado, o todos).
  • Qué salas y equipos dependen de cada artista, y el panel lateral que salta a su prueba o a su actuación.
  • Las etiquetas de cabecera a cinco minutos, la misión y todos los textos de esta página.

El código de esta demo

Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.

La integración: tres niveles de zoom, horas nocturnas ocultas, las etiquetas de cabecera a cinco minutos, el resaltado de filas con rows.selection y los callbacks donde las reglas rechazan un movimiento o un cambio de duración.

scheduler.tsxtsx
/**
 * 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} />
}
Descargar el código del ejemplo (.zip)El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.

Comparte este escenario

El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.

Otro negocio, un problema relacionadoReservas de pistas en un club deportivoA media mañana se rompe la red de una pista de pádel. Mueve la clase, cierra la pista y respeta el turno de cada monitor.Leer el caso de negocioRecintos y eventos