Skip to content

Construction programme planning

Rain stops a slab pour. Slip it, move the next floor’s frame in one go and keep the tower crane within its shift.

  • Construction
  • Plumbline
  • Resource tree
  • Dependencies
  • Summary rows
  • Multi-selection
  • Rectangle selection
  • Undo and redo
  • Minimap
  • Progress bars
  • Zoom slider
  • Demo with synthetic data

Mission

0 of 3 steps done

Wednesday 21 October, 07:00: rain is forecast from Thursday to Monday, and the L3 slab is due to be poured tomorrow.

  1. Take the L3 pour out of the rain: Tue 27 Oct. — To do

    On Level 3 · Shell, drag the red Pour bar three working days right, to Tuesday 27 October (the weekend is skipped). Or open its menu (right click or Shift+F10) and choose the first day it can go.

  2. Move the whole L4 frame three working days later. — To do

    L4 formwork now starts before the L3 slab has cured. Drag across the empty grid to draw a window over the three L4 bars (or Ctrl/⌘-click them), then drag one: they move together. Or choose Select the L4 frame in a bar’s menu and press Shift in the sheet.

  3. Clear the tower crane overload. — To do

    The L4 pour now lands on Wed 11 Nov with the facade and the lift-rail delivery: 11 hours of crane for an 8-hour shift. Drag Lift rails on Level 0 · Interior to Thu 12 Nov, or choose Next day with crane capacity in its menu.

About 30 seconds. Steps tick off as you complete them.

Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.

Plumbline
Level 4▽ +12.80ShellLevel 3▽ +9.60ShellInteriorLevel 2▽ +6.40ShellInteriorLevel 1▽ +3.20ShellInteriorLevel 0▽ ±0.00ShellInteriorFoundations▽ −1.20GroundGroundworks8 dPartitions10 dLift1 dFinishes20 dFacade4 dServices8 dInsp.1 dPartitions10 dFacade4 dServices8 dInsp.1 dPartitions10 dFormwork3 dRebar2 dPour1 dFacade4 dServices8 dFormwork3 dRebar2 dPour1 d762433322844777762Tower crane TC1h/day · shift 8 hW43W44W45W46W47W481920212223242526272829303112345678910111213141516171819202122232425

Preview of the first frameLive: the real Pro engine, running in your browserAbout 30 seconds. Steps tick off as you complete them. Keyboard: focus the grid, then use arrow keys, Enter and Alt+arrows to move.

The situation.

Plumbline is a fictional general contractor building Linden Yard, Block C: six storeys on a reinforced-concrete frame. Its programme is location-based: every level of the section has a shell (formwork, rebar, pour, then the facade) and an interior (services, inspection, partitions, finishes), and one crew per trade moves from floor to floor. A slab must cure seven days before the next floor’s formwork can stand on it.

Weather is the usual enemy. When a pour slips, the cure slips, the floor above slips, and the float that protected Topping out disappears without anyone noticing. A bar chart hides that cascade; this programme computes the critical path from the logic, draws it in red pencil, refuses what the site cannot do and keeps the tower crane and the workforce in view.

What you will do

You will move a pour out of the rain, shift a whole floor’s frame in one move after the curing rule flags it, and clear the tower-crane overload the slip creates.

Where the library ends and your application begins.

SuperScheduler provides

  • A three-level resource tree (Block C, its levels, their faces) with rowHeaderColumns and merged parent rows; the row headers draw the building’s section with each level’s datum and slab.
  • A frozen top row (frozen: 'top', cellsDisabled) that onBeforeCellRender fills with tower crane hours per day; cells hatched for weekends, curing slabs and rain, and disabled over the Christmas shutdown.
  • Three time-header rows written by onBeforeTimeHeaderRender: weeks as a dimension chain, days, and the forecast, with a header area marking the site clock; separators for the site clock and the crane’s last day.
  • Milestones, PercentComplete progress bars drawn as material hatches, finish-to-start links styled by onBeforeLinkRender with a link bubble, and time-anchored areas over the days an activity starts too early.
  • Multi-select by click, Ctrl/Cmd+click or a window drawn over the grid (rectangleSelectHandling, onGridMouseDown), allowMultiMove with every member rewritten per frame in onEventMoving, and eventMoveSkipNonBusiness.
  • The zoom slider of super-scheduler/zoom-ui as the drawing’s scale bar, the minimap of super-scheduler/minimap as the key plan, undo history, keyboard moves, the event hover card, the drag card and a context menu in the product’s skin.

This example’s code decides

  • The site calendar (weekends, the shutdown), the curing rule, finish-to-start logic, no pours in the rain, one crew per trade and fixed dates: refusals in onEventMoving and onEventMove, with the reason and the day they name.
  • The critical path method: late dates back from the fixed dates over working days, total float per activity, the critical activities and the links that drive them.
  • Tower crane hours and workers on site per day, the commands (first day it can go, next day with crane capacity, shift a selection by working days) and the revision letters kept from the history.
  • The drawing itself: hatches per trade, dimension lines, the section cut at the site clock, revision clouds and the title block.

The code behind this demo.

These are the files this page runs, not a simplified copy. They compile against the public SuperScheduler Pro exports.

tsx.zip

The integration: the tree and its columns, the frozen crane row, three header rows, separators, milestones and progress bars, links styled from the critical path with their bubble, window selection, multi-move with members rewritten per frame, the drag verdict, the hover card and the context menu, and the callbacks where the site’s rules refuse a move.

/**
 * Plumbline programme board: Linden Yard, Block C, drawn as its section. Levels from the roof
 * down, the faces of each level as rows, fifteen weeks of days on a drafting grid, the critical
 * path in red pencil and the tower crane booked day by day on top.
 *
 * Library: a three-level resource tree with rowHeaderColumns (merged parent rows), a frozen
 * summary row painted by onBeforeCellRender, three time-header rows written by
 * onBeforeTimeHeaderRender (a dimension chain of weeks, days, the forecast) with header areas,
 * disabled cells, separators, milestones, PercentComplete bars drawn as material hatches,
 * finish-to-start links styled by onBeforeLinkRender with a link bubble, multi-select (click,
 * Ctrl/Cmd+click, a window drawn over the grid with rectangleSelectHandling), allowMultiMove with
 * members rewritten per frame, eventMoveSkipNonBusiness, conflict highlighting, the zoom slider
 * (super-scheduler/zoom-ui), keyboard and undo history, the event hover card and the drag card
 * (super-scheduler/react-render) and a context menu. Application: the site calendar, curing,
 * logic, rain, crews and fixed dates, the critical path and float, crane hours and every figure.
 * Those rules live in ./rules; ./paint writes the markup.
 */
import { useCallback, useEffect, useMemo, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { createZoomSlider } from 'super-scheduler/zoom-ui'
import { format, plural } from '~/i18n/format'
import { DAY, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { useEventHover } from '../kit/hover-card'
import { KEYS } from '../kit/keys'
import { useDropRefusal } from '../kit/use-drop-refusal'
import { createDragCard } from '../kit/verdict'
import { skinClass, skinMenuTheme } from '../registry'
import type { DemoCommon } from '../types'
import {
  BOARD,
  BUILDING_ID,
  CRANE_ROW,
  ROW_HEADER_WIDTH,
  activityName,
  isLevelRowId,
  isRowId,
  levelOfRow,
  openingScroll,
  readActivityFields,
  toResources,
  type ActivityEvent,
} from './adapter'
import {
  activityCard,
  craneDayOf,
  firstValidOf,
  refusalText,
  type ActivityAction,
  type PlanView,
} from './card'
import {
  CRANE_DISMANTLED,
  CRANE_HOURS,
  INITIAL_VIEW_START,
  NOW,
  PLAN,
  ROWS,
  type Activity,
} from './model'
import {
  DAY_LETTERS_FROM,
  DAY_NUMBERS_FROM,
  buildingHtml,
  cardHtml,
  cellPainter,
  craneCell,
  craneHeaderHtml,
  dayHeader,
  doneHtml,
  faceHtml,
  keyDay,
  levelHtml,
  milestoneHtml,
  shortDay,
  tradeHtml,
  weatherHeader,
  weekHeader,
  type HeaderPaint,
} from './paint'
import {
  addWorkingDays,
  brokenSuccessors,
  craneHours,
  curingWindows,
  dayOf,
  duration,
  frameOf,
  groupRefusal,
  isMovable,
  isWorkingDay,
  linkId,
  nextWorkingStart,
  placementRefusal,
  pourOf,
  rowProgress,
  shiftStart,
  signedWorkingDays,
  slabState,
  tradeWork,
  workAt,
  type Span,
} from './rules'
import type { LogicView, PlumblineText } from './text'

const SLUG = 'construction-site'
const NOW_TICKS = parseCivil(NOW)
const PLAN_FROM = parseCivil(PLAN.start)

export interface PlumblineSchedulerProps {
  readonly events: ActivityEvent[]
  readonly view: PlanView
  readonly text: PlumblineText
  readonly common: DemoCommon
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  readonly logic: LogicView
  /** Narrow stages keep the location column only. */
  readonly narrow: boolean
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  /** Where the product bar keeps the zoom slider (the drawing's scale bar). */
  readonly zoomHost: RefObject<HTMLDivElement | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** A click (or Enter) on an activity: its sheet opens. */
  readonly onOpen: (id: string) => void
  readonly onMoved: (ids: readonly string[]) => void
  readonly onRefused: (id: string, message: string) => void
  readonly onAction: (action: ActivityAction, id: string) => void
  /** The settled cell width after a zoom, for the title block's scale. */
  readonly onScale: (cellWidth: number) => void
}

/** The last working day at or before `ticks` (a keyboard move to the left over a weekend). */
function previousWorkingStart(ticks: number): number {
  let day = dayOf(ticks)
  while (!isWorkingDay(day)) day -= DAY
  return day
}

/** onBeforeTimeHeaderRender: weeks, days and the forecast, cached per cell and width bucket. */
function createTimeHeaderHook(text: PlumblineText, tag: string) {
  const cache = new Map<string, HeaderPaint>()
  const nowLabel = format(text.chrome.now, { day: shortDay(NOW_TICKS, tag), time: '07:00' })
  return (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
    const header = args.header
    const start = header.start.getTime()
    const span = header.end.getTime() - start
    const width = args.control.cellWidth ?? BOARD.cellWidth
    const bucket = width >= DAY_LETTERS_FROM ? 'w' : width >= DAY_NUMBERS_FROM ? 'm' : 'n'
    const key = `${header.level}:${start}:${span}:${bucket}`
    let paint = cache.get(key)
    if (paint === undefined) {
      paint =
        header.level === 0
          ? weekHeader(start, span, text, tag)
          : header.level === 1
            ? dayHeader(start, span, width, tag)
            : weatherHeader(start, span, width, text, nowLabel)
      cache.set(key, paint)
    }
    header.html = paint.html
    header.cssClass = paint.cssClass
    header.areas = paint.areas
    header.toolTip = paint.toolTip
  }
}

export function PlumblineScheduler(props: PlumblineSchedulerProps) {
  const { events, view, text, common, tag, schedulerLocale, logic, narrow, history } = props
  const { controlRef, zoomHost } = props
  const { onEventsChange, onOpen, onMoved, onRefused, onAction, onScale } = props

  const resources = useMemo(() => toResources(text), [text])
  const refusal = useDropRefusal(onRefused)

  const name = useCallback((activity: Activity) => activityName(activity, text), [text])

  // Hover card: a drawing callout with the logic on both sides, the float and the revision.
  const card = useCallback(
    (event: SuperScheduler.Event) => {
      const activity = view.net.byId.get(String(event.id()))
      return activity === undefined ? null : activityCard(activity, view, text, common, tag)
    },
    [view, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useEventHover(card)

  // Drag verdict: the site's refusal, or the successors the drop would push out of their logic.
  const dragCard = useMemo(
    () =>
      createDragCard({
        tag,
        common,
        days: true,
        rowName: (id) => {
          const row = ROWS.find((candidate) => candidate.id === id)
          return row === undefined
            ? null
            : `${text.levelShort[row.level]} · ${text.faces[row.face]}`
        },
        warn: (info) => {
          const activity = view.net.byId.get(String(info.eventId))
          if (activity === undefined) return null
          const span = { start: info.start.getTime(), end: info.end.getTime() }
          const broken = brokenSuccessors(activity, span, view.net)
          if (broken.length === 0) return null
          const names = broken
            .flatMap((item) => {
              const next = view.net.byId.get(item.id)
              return next === undefined ? [] : [`${name(next)} (${shortDay(item.until, tag)})`]
            })
            .join(', ')
          return plural(text.card.pushes, broken.length, tag, { names })
        },
      }),
    [view, text, common, tag, name],
  )

  // Context menu: rebuilt when the plan changes, so its labels name the real day.
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: skinMenuTheme(SLUG),
        title: (event) => format(common.kit.menuTitle, { name: event.text() }),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const activity = view.net.byId.get(id)
          if (activity === undefined) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (view.issueOf.has(id) && isMovable(activity)) {
            const first = firstValidOf(activity, view)
            entries.push({
              label:
                first === null
                  ? text.actions.noValid
                  : format(text.actions.firstValid, { date: shortDay(first, tag) }),
              icon: 'slot',
              primary: true,
              disabled: first === null,
              run: () => onAction('first-valid', id),
            })
          }
          if (activity.trade === 'lift' && isMovable(activity)) {
            const day = craneDayOf(activity, view)
            entries.push({
              label:
                day === null
                  ? text.actions.noCraneDay
                  : format(text.actions.craneDay, { date: shortDay(day, tag) }),
              icon: 'after',
              primary: !view.issueOf.has(id),
              disabled: day === null,
              run: () => onAction('crane-day', id),
            })
          }
          const row = ROWS.find((candidate) => candidate.id === activity.row)
          const frame = frameOf(view.activities, activity.row).filter(isMovable)
          const trade = activity.trade === null ? [] : tradeWork(view.activities, activity.trade)
          const selections: MenuEntry[] = []
          if (row !== undefined && frame.some((member) => member.id === id) && frame.length > 1)
            selections.push({
              label: format(text.actions.selectFrame, {
                level: text.levelShort[row.level],
                count: String(frame.length),
              }),
              icon: 'focus',
              run: () => onAction('select-frame', id),
            })
          if (activity.trade !== null && trade.length > 1)
            selections.push({
              label: format(text.actions.selectTrade, {
                trade: text.trades[activity.trade],
                count: String(trade.length),
              }),
              icon: 'layers',
              run: () => onAction('select-trade', id),
            })
          if (selections.length > 0) entries.push('separator', ...selections)
          entries.push('separator', {
            label: logic === 'all' ? text.actions.showCritical : text.actions.showAll,
            icon: 'link',
            run: () => onAction('toggle-logic', id),
          })
          return entries
        },
      }),
    [view, logic, common, text, tag, suppressHover, onAction],
  )

  // The logic from the model; the critical path in red pencil, broken links as clashes. Abutting
  // bars of one face read as a sequence already (like a dimension chain): their link is drawn only
  // when it breaks. The bubble (bubbleHtml) is part of each link's data: lag and the float left.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const broken = new Set(
      view.issues.flatMap((issue) =>
        issue.other === undefined ? [] : [`${issue.other}->${issue.activity}`],
      ),
    )
    return view.net.links.flatMap((link): SuperScheduler.LinkData[] => {
      const id = linkId(link)
      const critical = view.plan.criticalLinks.has(id)
      const clash = broken.has(id)
      if (logic === 'critical' && !critical && !clash) return []
      const from = view.net.byId.get(link.from)
      const to = view.net.byId.get(link.to)
      if (from === undefined || to === undefined) return []
      if (!clash && link.lag === 0 && from.row === to.row && from.end === to.start) return []
      const required = parseCivil(from.end) + link.lag * DAY
      const gap = signedWorkingDays(nextWorkingStart(required), parseCivil(to.start))
      const lagText =
        link.lag > 0 ? format(text.card.curedOn, { date: shortDay(required, tag) }) : ''
      const gapText =
        gap < 0
          ? format(text.card.negative, { count: String(-gap) })
          : gap === 0
            ? text.card.critical
            : format(text.card.float, { count: String(gap) })
      return [
        {
          id,
          from: link.from,
          to: link.to,
          type: 'FinishToStart',
          cssClass: clash
            ? 'pl-link pl-link--clash'
            : critical
              ? 'pl-link pl-link--critical'
              : 'pl-link',
          bubbleHtml:
            `<span class="pl-lb"><b>${escape(name(from))} → ${escape(name(to))}</b>` +
            (lagText === '' ? '' : `<span>${escape(lagText)}</span>`) +
            `<span class="pl-lb__gap" data-gap="${gap < 0 ? 'clash' : gap === 0 ? 'zero' : 'free'}">${escape(gapText)}</span></span>`,
        },
      ]
    })
  }, [view, logic, text, tag, name])

  // The link bubble shows a link's lag and the float it leaves (one instance, the menu's skin).
  const linkBubble = useMemo(
    () =>
      new SuperScheduler.Bubble({
        theme: skinMenuTheme(SLUG),
        showAfter: 120,
        position: 'Mouse',
        hideOnClick: true,
      }),
    [],
  )

  // The separators: the site clock as a section cut, and the day the tower crane comes down.
  const separators = useMemo<SuperScheduler.SeparatorData[]>(
    () => [
      {
        location: `${CRANE_DISMANTLED}T00:00:00`,
        layer: 'BelowEvents',
        width: 1,
        cssClass: 'pl-sep-crane',
        toolTip: format(text.kpi.toppingTip, { date: shortDay(parseCivil(CRANE_DISMANTLED), tag) }),
      },
      {
        location: NOW,
        layer: 'AboveEvents',
        width: 2,
        cssClass: 'pl-sep-now',
        toolTip: format(text.chrome.now, {
          day: shortDay(NOW_TICKS, tag),
          time: new Intl.DateTimeFormat(tag, {
            hour: '2-digit',
            minute: '2-digit',
            hourCycle: 'h23',
            timeZone: 'UTC',
          }).format(new Date(NOW_TICKS)),
        }),
      },
    ],
    [text, tag],
  )

  // Header markup per cell, cached for the board's life (the hook runs for every header cell).
  const timeHeaders = useMemo(() => createTimeHeaderHook(text, tag), [text, tag])

  const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () =>
      narrow
        ? [{ text: text.columns.location, width: BOARD.narrowColumn, nonresizable: true }]
        : [
            { text: text.columns.location, width: BOARD.locationColumn, nonresizable: true },
            { text: text.columns.trade, width: BOARD.tradeColumn, nonresizable: true },
            { text: text.columns.done, width: BOARD.doneColumn, nonresizable: true },
          ],
    [narrow, text],
  )

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const activities = view.activities
    const byId = view.net.byId
    // Row figures, once per plan: progress, the trade at work, critical faces, slabs.
    const progress = new Map(ROWS.map((row) => [row.id, rowProgress(activities, row.id)]))
    const overall =
      ROWS.reduce((sum, row) => sum + (progress.get(row.id) ?? 0), 0) / Math.max(1, ROWS.length)
    const criticalRows = new Set(
      activities.flatMap((activity) => (view.plan.critical.has(activity.id) ? [activity.row] : [])),
    )
    const paintCell = cellPainter(curingWindows(activities))
    const crane = craneHours(activities, PLAN_FROM, PLAN.days)
    const cards = new Map<string, string>()

    /** Members of a multi-move, each shifted by the dragged activity's working days. */
    const groupSpans = (
      master: Activity,
      start: number,
      items: readonly SuperScheduler.SchedulerMultiMoveItem[],
    ) => {
      const delta = signedWorkingDays(parseCivil(master.start), start)
      const spans = new Map<string, Span>()
      const members: Activity[] = []
      for (const item of items) {
        const member = byId.get(String(item.event.id()))
        if (member === undefined) continue
        const from = shiftStart(parseCivil(member.start), delta)
        spans.set(member.id, { start: from, end: addWorkingDays(from, duration(member)) })
        members.push(member)
      }
      return { delta, spans, members }
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: PLAN.start,
      days: PLAN.days,
      // ISO weeks in every locale: the week row is a dimension chain from Monday to Monday.
      weekStarts: 1,
      scale: 'Day',
      cellWidth: BOARD.cellWidth,
      zoomGesture: { min: BOARD.zoom.min, max: BOARD.zoom.max, scales: false },
      // The drawing keeps its lettering down to a narrow day; it simplifies further out.
      lod: { zoomedOut: 20, eventFull: 26, eventText: 20, cellContent: 12, dayNumber: 13 },
      timeHeaders: [
        { groupBy: 'Week', height: BOARD.header.week },
        { groupBy: 'Day', height: BOARD.header.day },
        { groupBy: 'Day', height: BOARD.header.weather },
      ],
      // Weekends are the site's: drawn by the cells, and skipped by moves.
      cellsMarkBusiness: false,
      businessWeekends: false,
      eventMoveSkipNonBusiness: true,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: narrow ? 4 : BOARD.treeIndent,
      rowHeaderColumns: columns,
      rowHeaderColumnsMergeParents: true,
      rowHeaderWidth: narrow ? BOARD.narrowColumn : ROW_HEADER_WIDTH,
      rowHeaderWidthAutoFit: false,
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMargin,
      rowMarginBottom: BOARD.rowMargin,
      allowEventOverlap: false,
      conflictHighlight: true,
      // Progress fills the trade's hatch from the left: the bar spans the whole card.
      durationBarVisible: true,
      durationBarMode: 'PercentComplete',
      durationBarHeight: 3,
      // Selection: a click, Ctrl/Cmd+click, or a window drawn over the empty grid (CAD style).
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      allowMultiMove: true,
      rectangleSelectHandling: 'EventSelect',
      timeRangeSelectedHandling: 'Disabled',
      linkShape: 'RightAngled',
      linkLayer: 'Below',
      linkBubble,
      crosshairType: 'Full',
      separators,
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      // The corner names the drawing: section A–A of Block C.
      onBeforeCornerRender: (args) => {
        args.html =
          `<span class="pl-corner"><b>${escape(text.section)}</b>` +
          `<span>${escape(text.building)}</span></span>`
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const [first, second, third] = row.columns
        if (id === CRANE_ROW) {
          row.cssClass = 'pl-row pl-row--crane'
          if (first !== undefined) first.html = craneHeaderHtml(text)
          if (second !== undefined) second.html = ''
          if (third !== undefined)
            third.html = `<span class="pl-cap" aria-hidden="true"><i></i>${CRANE_HOURS} h</span>`
          return
        }
        if (id === BUILDING_ID) {
          row.cssClass = 'pl-row pl-row--building'
          row.html = buildingHtml(text, overall)
          if (first !== undefined) first.html = row.html
          return
        }
        if (isLevelRowId(id)) {
          const level = levelOfRow(id)
          row.cssClass = 'pl-row pl-row--level'
          row.html = levelHtml(level, slabState(pourOf(activities, level)), text, tag)
          if (first !== undefined) first.html = row.html
          return
        }
        if (!isRowId(id)) return
        const critical = criticalRows.has(id)
        row.cssClass = 'pl-row pl-row--face'
        if (first !== undefined) first.html = faceHtml(String(row.name), critical)
        if (second !== undefined) second.html = tradeHtml(workAt(activities, id), text, tag)
        if (third !== undefined) third.html = doneHtml(progress.get(id) ?? 0)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const day = cell.start.getTime()
        const wide = (args.control.cellWidth ?? BOARD.cellWidth) >= 26
        if (cell.resource === CRANE_ROW) {
          const hours = crane[Math.round((day - PLAN_FROM) / DAY)] ?? 0
          const painted = craneCell(hours, wide, text)
          cell.properties.cssClass = painted.css
          cell.properties.html = painted.html
          return
        }
        if (cell.resource === BUILDING_ID || isLevelRowId(cell.resource)) {
          cell.properties.cssClass = isWorkingDay(day) ? 'pl-c-slab' : 'pl-c-slab pl-c-off'
          return
        }
        if (!isRowId(cell.resource)) return
        const painted = paintCell(cell.resource, day, wide, text)
        if (painted.css !== '') cell.properties.cssClass = painted.css
        if (painted.html !== '') cell.properties.html = painted.html
        if (painted.disabled) cell.properties.disabled = true
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const fields = readActivityFields(data)
        const id = String(data.id)
        const activity = byId.get(id)
        if (fields === null || activity === undefined) return
        const issue = view.issueOf.get(id) ?? null
        const critical = view.plan.critical.has(id)
        const revision = view.revisionOf.get(id) ?? null
        const classes = ['pl-ev', `pl-k-${activity.kind}`]
        if (activity.trade !== null) classes.push(`pl-t-${activity.trade}`)
        if (critical) classes.push('pl-ev--critical')
        if (issue !== null) classes.push('pl-ev--clash')
        if (revision !== null) classes.push('pl-ev--revised')
        if (activity.complete >= 100) classes.push('pl-ev--built')
        else if (activity.complete > 0) classes.push('pl-ev--running')
        data.cssClass = classes.join(' ')
        const label = activityName(activity, text)

        if (activity.kind === 'milestone') {
          // A key date: a diamond at the end of its day, its name and day beside it.
          data['type'] = 'Milestone'
          data['width'] = 18
          data.html = ''
          data.barHidden = true
          const note = issue === null ? null : text.card.conflict
          data.htmlRight =
            (args.control.cellWidth ?? BOARD.cellWidth) >= DAY_NUMBERS_FROM
              ? milestoneHtml(label, keyDay(parseCivil(activity.start), tag), note)
              : ''
          data.ariaLabel = `${label}, ${keyDay(parseCivil(activity.start), tag)}`
          return
        }

        const days = duration(activity)
        const width = Math.round(
          ((parseCivil(activity.end) - parseCivil(activity.start)) / DAY) *
            (args.control.cellWidth ?? BOARD.cellWidth),
        )
        const bucket =
          width >= 110
            ? 110
            : width >= 96
              ? 96
              : width >= 64
                ? 64
                : width >= 58
                  ? 58
                  : width >= 30
                    ? 30
                    : 0
        const key = `${id}|${bucket}|${revision ?? ''}|${activity.complete}`
        let html = cards.get(key)
        if (html === undefined) {
          html = cardHtml(activity, label, days, { critical, issue, revision, width: bucket }, text)
          cards.set(key, html)
        }
        data.html = html
        data.complete = activity.complete
        if (activity.kind !== 'work') data.barHidden = true
        // The days an activity starts too early: hatched in red pencil, from its start.
        const areas: SuperScheduler.AreaData[] = []
        if (issue !== null) {
          const end =
            issue.kind === 'rain'
              ? activity.end
              : civil(Math.min(issue.until, parseCivil(activity.end)))
          areas.push({
            start: activity.start,
            end,
            top: 0,
            bottom: 0,
            cssClass: 'pl-clash',
            action: 'Default',
          })
        }
        data.areas = areas
        const state =
          issue !== null ? `, ${text.card.conflict}` : critical ? `, ${text.card.critical}` : ''
        data.ariaLabel = `${label}, ${shortDay(parseCivil(activity.start), tag)}, ${plural(text.days, days, tag)}${state}`
      },

      // The critical path is drawn heavier than the leader lines of the rest of the logic.
      onBeforeLinkRender: (args) => {
        if ((args.data.cssClass ?? '').includes('critical')) args.data.width = 2
      },

      // A plain drag over the empty grid draws a selection window, as in a drawing program.
      onGridMouseDown: (args) => {
        if (args.button === 'left') args.action = 'RectangleSelect'
      },

      // Every frame: the site's rules for the dragged activity or the whole selection moved
      // together (each member shifted by the same working days, rewritten here).
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const activity = byId.get(id)
        if (activity === undefined) return
        if (args.multimove.length > 1) {
          const group = groupSpans(activity, args.start.getTime(), args.multimove)
          for (const item of args.multimove) {
            const span = group.spans.get(String(item.event.id()))
            if (span === undefined) continue
            item.start = new SuperScheduler.Date(span.start)
            item.end = new SuperScheduler.Date(span.end)
          }
          const refused = groupRefusal(group.members, group.spans, view.net)
          if (refused !== null) {
            const member = byId.get(refused.activity)
            const message = `${member === undefined ? '' : `${name(member)}: `}${refusalText(refused, view, text, tag)}`
            args.allowed = false
            args.message = message
            refusal.track(id, message)
            return
          }
          refusal.track(id, null)
          return
        }
        const refused = placementRefusal(
          activity,
          { start: args.start.getTime(), end: args.end.getTime() },
          view.net,
        )
        if (refused !== null) {
          const message = refusalText(refused, view, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        refusal.track(id, null)
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag and
      // are kept on working days in the direction they travel.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const activity = byId.get(id)
        if (activity === undefined) return
        if (args.multimove.length > 1) {
          const group = groupSpans(activity, args.newStart.getTime(), args.multimove)
          const refused = groupRefusal(group.members, group.spans, view.net)
          if (refused === null) return
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalText(refused, view, text, tag))
          return
        }
        const before = parseCivil(activity.start)
        let start = args.newStart.getTime()
        if (!isWorkingDay(dayOf(start)))
          start = start >= before ? nextWorkingStart(start) : previousWorkingStart(start)
        const end = addWorkingDays(start, duration(activity))
        if (start !== args.newStart.getTime() || end !== args.newEnd.getTime()) {
          args.newStart = new SuperScheduler.Date(start)
          args.newEnd = new SuperScheduler.Date(end)
        }
        const refused = placementRefusal(activity, { start, end }, view.net)
        if (refused === null) return
        args.preventDefault()
        refusal.clear()
        onRefused(id, refusalText(refused, view, text, tag))
      },

      onEventMoved: (args) => {
        refusal.clear()
        const ids =
          args.multimove.length > 0
            ? args.multimove.map((item) => String(item.event.id()))
            : [String(args.e.id())]
        onMoved(ids)
      },

      // A click (or Enter) selects the activity and docks its sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        if (args.ctrl || args.meta) return
        onOpen(String(args.e.id()))
      },

      onZoom: (args) => {
        if (args.phase === 'end') onScale(args.cellWidth)
      },
    }
  }, [
    view,
    columns,
    narrow,
    text,
    tag,
    schedulerLocale,
    history,
    controlRef,
    separators,
    linkBubble,
    refusal,
    name,
    closeHover,
    onEventsChange,
    onMoved,
    onOpen,
    onRefused,
    onScale,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update. The board opens on
  // the week of the slip, scrolled down to Level 4.
  useEffect(() => {
    const control = controlRef.current
    control?.scrollTo(INITIAL_VIEW_START, false, 'left')
    control?.setScrollY(openingScroll())
  }, [controlRef])

  // The zoom slider lives in the product bar, styled as the drawing's scale bar.
  useEffect(() => {
    const control = controlRef.current
    const host = zoomHost.current
    if (control === null || host === null) return
    const slider = createZoomSlider(control, host, {
      min: BOARD.zoom.min,
      max: BOARD.zoom.max,
      scale: 'log',
      detents: BOARD.zoom.detents,
      label: text.chrome.scale,
    })
    return () => slider.dispose()
  }, [controlRef, zoomHost, text])

  return (
    <SuperSchedulerComponent
      {...config}
      onBeforeTimeHeaderRender={timeHeaders}
      resources={resources}
      events={events}
      links={links}
      eventHover={eventHover}
      dragCard={dragCard}
      contextMenu={contextMenu}
    />
  )
}

The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.