Skip to content
SuperScheduler

ManufacturingForge (Fictional business)

Manufacturing order scheduling

Maintenance was brought forward. Move the order out of the way and keep its operations in sequence.

Time scale
Demo with synthetic data
shifts / hours
Pro capabilities
Resource treeDependenciesBlocked timeMinimap
Forge
MachiningCNC mill 1Crew ACNC mill 2Crew ALatheCrew BAssemblyCell ACrew CCell BCrew CFinishingPaint boothCrew DQuality labCrew DWO-4471 MillingWO-4471 TurningWO-4478 TurningWO-4471 AssemblyWO-4472 AssemblyWO-4472 InspectionWO-4472 PaintingWO-4478 MillingTuesday, October 20Wednesday, October 215 AM6 AM7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM8 PM9 PM10 PM11 PM12 AM1 AM2 AM3 AM4 AM5 AM6 AM7 AM

Loads SuperScheduler Pro only when you ask for it.

The situation

Forge is a fictional contract machining plant: two CNC mills and a lathe, two assembly cells, a paint booth and a quality lab, run by four crews over three shifts. Every work order follows a routing (milling, turning, assembly, painting, inspection), and no operation may start before the previous one ends.

Maintenance does not wait for the plan. When the spindle service of CNC mill 2 is brought forward, the milling planned on it has to move, and whatever follows it in the routing may now start too early. A planning board has to refuse impossible moves, show which operations are out of sequence and tell the planner where the week is already full.

What you will doYou will move a milling operation off a maintenance window, put its turning operation back in sequence, and use the load strip to jump to the next maintenance on Thursday.

Where the library ends and your application begins

SuperScheduler provides

  • A line and machine tree, dragging between machines and overlap prevention per machine.
  • Disabled cells that refuse drops on maintenance windows, with per-frame vetoes and messages.
  • Finish-to-start links drawn between operations, styled link by link.
  • Hour and shift scales with animated zoom, keyboard moves and announcements.
  • The minimap from super-scheduler/minimap and viewport state from super-scheduler/hooks.

This example’s code decides

  • Which kind of machine runs each operation, and the maintenance calendar.
  • Each order’s routing and the check that flags an operation starting too early.
  • The load series: busy machine time over available machine time, with maintenance taken out.
  • Fixed run times (resizing is off), the detail panel, the mission and every label.

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: hour and shift scales, maintenance as disabled cells, sequence links, the refusals, and the load strip with its computed series.

scheduler.tsxtsx
/**
 * Forge production board: production lines and their machines over one week of three shifts,
 * with each work order's operations linked in sequence and a plant-load strip underneath.
 *
 * Library: tree rows, drag between machines, disabled cells, finish-to-start links, hour and
 * shift scales, the minimap (super-scheduler/minimap) and viewport state (super-scheduler/hooks).
 * Application: which machine runs which operation, maintenance windows, what counts as out of
 * sequence, and the load series the strip draws. Those rules live in ./rules.
 */
import { useEffect, useMemo, useRef, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { subscribeScheduler } from 'super-scheduler/hooks'
import { createMinimap, type Minimap } from 'super-scheduler/minimap'
import { civil, formatCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
  ORDER_TONES,
  civilOf,
  isOperationEvent,
  machineOf,
  readOperationFields,
  toResources,
  type OperationEvent,
} from './adapter'
import { INITIAL_VIEW_START, MACHINES, MAINTENANCE, WEEK, type Operation } from './model'
import { loadSeries, maintenanceAt, slotRefusal, type Refusal, type SequenceIssue } from './rules'
import { ZOOM_IDS, type ForgeText, type ZoomId } from './text'

export interface ForgeSchedulerProps {
  readonly events: OperationEvent[]
  /** The same operations in model form: the load strip's series is computed from them. */
  readonly operations: readonly Operation[]
  readonly issues: readonly SequenceIssue[]
  /** Operations that sit on a maintenance window. */
  readonly conflicts: readonly string[]
  readonly text: ForgeText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Scale on mount. Later changes go through control.zoom so the view animates. */
  readonly initialZoom: ZoomId
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onRefused: (id: string, refusal: Refusal) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /** The visible range once scrolling settles (civil ISO strings). */
  readonly onViewport: (start: string, end: string) => void
}

/** Hour cells for precise moves; one cell per eight-hour shift (00, 08, 16) for the week view. */
const SCALES: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
  hours: { scale: 'Hour', cellWidth: 48, timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }] },
  shifts: {
    scale: 'CellDuration',
    cellDuration: 480,
    cellWidth: 112,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
}

// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
  id,
  properties: SCALES[id],
}))

/** Every operation the control holds right now, in the shape the rules read. */
function operationsIn(
  control: SuperScheduler.Scheduler,
): Pick<Operation, 'id' | 'machine' | 'start' | 'end'>[] {
  return control.events.list.flatMap((event) => {
    const machine = event.resource === undefined ? null : machineOf(event.resource)
    if (machine === null || !isOperationEvent(event)) return []
    return [
      {
        id: String(event.id),
        machine: machine.id,
        start: civilOf(event.start),
        end: civilOf(event.end),
      },
    ]
  })
}

/** Checks one proposed slot of an operation against the plant's rules. */
function refusalFor(
  control: SuperScheduler.Scheduler | null,
  data: object,
  id: string,
  resource: SuperScheduler.ResourceId,
  start: SuperScheduler.Date,
  end: SuperScheduler.Date,
): Refusal | null {
  const fields = readOperationFields(data)
  const machine = machineOf(resource)
  if (control === null || fields === null || machine === null) return null
  return slotRefusal(
    { id, kind: fields.kind, start: civil(start.ticks), end: civil(end.ticks) },
    machine,
    MAINTENANCE,
    operationsIn(control),
  )
}

export function ForgeScheduler(props: ForgeSchedulerProps) {
  const {
    events,
    operations,
    issues,
    conflicts,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    controlRef,
  } = props
  const { onEventsChange, onSelect, onMoved, onRefused, onZoomLevel, onViewport } = props

  const resources = useMemo(() => toResources(text), [text])
  const refusal = useDropRefusal(onRefused)
  const strip = useRef<HTMLDivElement>(null)
  const minimap = useRef<Minimap | null>(null)

  // Each order's routing is a chain of finish-to-start links; late ones are drawn in red.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const late = new Set(issues.map((issue) => issue.operation))
    return events.flatMap((event) =>
      event.after === undefined
        ? []
        : [
            {
              id: `${event.after}->${String(event.id)}`,
              from: event.after,
              to: event.id,
              type: 'FinishToStart',
              ...(late.has(String(event.id)) ? { cssClass: 'ss-link--late' } : {}),
            },
          ],
    )
  }, [events, issues])

  const config = useMemo<SchedulerProps>(() => {
    const flagged = new Set([...issues.map((issue) => issue.operation), ...conflicts])
    const escape = SuperScheduler.Util.escapeHtml
    const messageFor = (reason: Refusal, data: object) => {
      const fields = readOperationFields(data)
      if (reason === 'machine') return fields === null ? '' : text.rules.wrongMachine[fields.kind]
      return text.rules[reason]
    }

    return {
      locale: schedulerLocale,
      startDate: WEEK.start,
      days: WEEK.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Three shifts: every hour is production time, so nothing is shaded as non-business.
      cellsMarkBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: 184,
      rowHeaderWidthAutoFit: false,
      eventHeight: 28,
      rowMarginTop: 5,
      rowMarginBottom: 5,
      allowEventOverlap: false,
      // Run time follows quantity and cycle time: planners move operations, they do not stretch them.
      eventResizeHandling: 'Disabled',
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      dragCard: { duration: false },
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: createHeaderHook(tag),

      onBeforeRowHeaderRender: (args) => {
        const machine = machineOf(args.row.id)
        if (machine === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        args.row.html =
          `<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
          `<span class="truncate font-medium">${escape(args.row.name)}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3">${escape(text.crews[machine.crew])}</span></span>`
      },

      // Maintenance windows become disabled cells: the library refuses drops on them.
      onBeforeCellRender: (args) => {
        const machine = machineOf(args.cell.resource)
        if (machine === null) return
        const cellStart = civil(args.cell.start.ticks)
        const window = maintenanceAt(MAINTENANCE, machine.id, cellStart, civil(args.cell.end.ticks))
        if (window === null) return
        args.cell.properties.disabled = true
        args.cell.properties.cssClass = 'ss-cell--maintenance'
        if (cellStart <= window.start) {
          // The first cell carries the label. The engine clips cells with an inline style, so the
          // important utility lets the label run over the rest of the window.
          args.cell.properties.cssClass = 'ss-cell--maintenance overflow-visible!'
          args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-0.5 text-[10.5px] font-semibold text-ink-2">${escape(text.maintenance[window.kind])}</span>`
        }
      },

      onBeforeEventRender: (args) => {
        const fields = readOperationFields(args.data)
        if (fields === null) return
        args.data.html =
          `<span class="ss-evt"><span class="ss-evt__title">${escape(fields.order)}</span>` +
          `<span class="ss-evt__meta">${escape(text.operations[fields.kind])}</span></span>`
        const classes = ['ss-ev', toneClass(ORDER_TONES[fields.order])]
        if (flagged.has(String(args.data.id))) classes.push('ss-ev--late')
        args.data.cssClass = classes.join(' ')
      },

      onEventMoving: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          controlRef.current,
          args.e.data,
          id,
          args.resource,
          args.start,
          args.end,
        )
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = messageFor(reason, args.e.data)
        }
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          args.control,
          args.e.data,
          id,
          args.newResource,
          args.newStart,
          args.newEnd,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()))
      },
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    conflicts,
    controlRef,
    initialZoom,
    issues,
    onEventsChange,
    onMoved,
    onRefused,
    onSelect,
    onZoomLevel,
    refusal,
    schedulerLocale,
    tag,
    text,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  // The visible range, published once scrolling (or a minimap drag) settles.
  useEffect(() => {
    const control = controlRef.current
    if (control === null) return
    return subscribeScheduler(control, 'viewport', (viewport) =>
      onViewport(civil(viewport.start.ticks), civil(viewport.end.ticks)),
    )
  }, [controlRef, onViewport])

  // The load strip reads the latest operations through a ref and redraws when they change.
  const latest = useRef(operations)
  useEffect(() => {
    latest.current = operations
    minimap.current?.refresh()
  }, [operations])

  useEffect(() => {
    const control = controlRef.current
    const host = strip.current
    if (control === null || host === null) return
    // The brush reports exact instants (with milliseconds): format their ticks.
    const when = (date: SuperScheduler.Date) =>
      formatCivil(date.ticks, tag, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
    const created = createMinimap(control, host, {
      height: 28,
      // Absolute scale: a full bar means every available machine is busy.
      peak: 'absolute',
      max: 1,
      series: (range) =>
        loadSeries(
          latest.current,
          MAINTENANCE,
          MACHINES,
          range.start.ticks,
          range.bucketMs,
          range.buckets,
        ),
      tone: (value) => (value >= 0.9 ? 'danger' : value >= 0.7 ? 'warn' : 'base'),
      // A fixed week of fixtures: no "today" tick, no washed-out past.
      marks: { today: false, months: false, past: false },
      labels: {
        label: text.load.label,
        zoom: text.load.zoom,
        valueText: (start, end) =>
          text.load.range.replace('{start}', when(start)).replace('{end}', when(end)),
      },
    })
    minimap.current = created
    return () => {
      created.dispose()
      minimap.current = null
    }
  }, [controlRef, tag, text])

  return (
    <div className="flex h-full flex-col">
      <div className="min-h-0 flex-1 [&>div]:h-full">
        <SuperSchedulerComponent {...config} resources={resources} events={events} links={links} />
      </div>
      <div className="flex shrink-0 items-center gap-3 border-t border-line bg-paper-2/60 px-3 py-2">
        <span className="w-[10.75rem] shrink-0 truncate text-xs font-medium text-ink-2 max-sm:w-auto">
          {text.load.title}
        </span>
        <div ref={strip} className="min-w-0 flex-1" />
      </div>
    </div>
  )
}
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 problemPort berth planningA ship arrives twelve hours late. Move its berth window, then bring its tug and cranes along.Read the business caseManufacturing