Skip to content

Kiln firing schedule

A member’s cone-10 stoneware is ready. Fire it in the only kiln that can, when someone can unload it, without blowing the energy budget.

  • Ceramics studio
  • Cone Ten
  • External drag and drop
  • React render slots
  • Drag and resize
  • Move rules
  • Dependencies
  • Active areas
  • Summary rows
  • Minimap
  • Theming
  • Demo with synthetic data

Mission

0 of 3 steps done

Wednesday, 10:15. Inés’s stoneware dinner set is bisqued and glazed: it needs cone 10, in reduction.

  1. Load Inés’s cone-10 dinner set into Dragon, the gas kiln. — To do

    Drag the INÉS sticker from the shelf onto Dragon’s row, after the firing that is cooling there. Pip and Bess refuse it: they top out at cone 6 and cone 8. Or press LOAD on the sticker.

  2. Make sure someone is here when it can be unloaded. — To do

    Loaded this afternoon, it cools down in the middle of the night. Drag it to Thursday 08:00, or right-click it and choose Next clean start.

  3. Bring the week back under its €590 energy budget. — To do

    The Wednesday class bisque on Bess ramps through the 17:00–21:00 peak, while Pip ramps too. Drag it to Thursday 08:00, or right-click it and choose Cheapest start.

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

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

Cone Ten
PipC6 · 85 LBessC8 · 220 LDragonC11 · 700 LEmberC06 · 60 LKaiMugs · 18NoorBowls · 12NoorBowls · 12MargitTiles · 40LiorJugs · 8LiorJugs · 8OluVases · 6Wed classBowls · 32AdePlatters · 5SolCups · 20RuthTeapots · 7Sat classPlates · 26Burner serviceShopTea bowls · 8PiaVases · 4MeiBeakers · 6ShopBeakers · 107031626171033300017181192035117003511910181870003511700ElectricitykWh per hourWednesday, October 21Thursday, October 22Friday, October 23Saturday, October 24Sunday, October 25Monday, October 26Tuesday, October 27Wednesday, October 280004081216200004081216200004081216200004081216200004081216200004081216200004081216200004

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.

Cone Ten is a fictional shared ceramics studio with four kilns: two electric kilns for bisque and cone-6 glaze, a gas car kiln for cone-10 reduction and a raku drum. Members bring greenware to the shelf; the studio fires it. A firing is not a block of time but a curve: the program ramps the kiln to its cone, holds it there, and then the brick cools on its own until the kiln can be opened below 200 °C.

That curve is what a planner has to see. The cooling is fixed by the kiln’s mass and nobody can shorten it; somebody has to be there to load and to unload; a bisque has to be out a day before its glaze firing; and electricity costs two and a half times as much between 17:00 and 21:00, with a contract that trips the breaker if both electric kilns ramp together. This board draws every firing as its own temperature curve, refuses what the kilns cannot do and keeps the energy bill in view.

What you will do

You will load a cone-10 batch into the gas kiln, move it so someone is there to unload it, and bring the week under its energy budget by taking a bisque out of the peak tariff.

Where the library ends and your application begins.

SuperScheduler provides

  • Event content from React render slots (renderEvent), with the same markup as an HTML fallback: each firing draws its temperature curve over the exact span of the event, so the peak and the end of cooling sit under their own time in the header.
  • External drag (SuperScheduler.Scheduler.makeDraggable): batches go from the shelf onto a kiln; onEventMoving fits the shadow to the program on the kiln under the pointer and refuses, with args.message, what that kiln cannot fire.
  • Moves and resizes steered frame by frame: onEventMoving and onEventResizing round the load to the quarter hour, let the end edge change only the hold, and refuse to shorten the cooling; the drag card (dragCard.html) shows load, peak, unload and cost.
  • Time-anchored areas (the cone on its peak, the temperature at the studio clock, hazard stripes over a breaker overload, a flag on an unload nobody can do) and hover areas (visibility: 'Hover') as big pills.
  • A frozen summary row painted by onBeforeCellRender, three time-header rows written by onBeforeTimeHeaderRender (the day, the studio’s open hours as header areas, the clock), business-hours shading, separators at midnight and at the studio clock, and the full crosshair.
  • Finish-to-start links, zoom levels from hours to the month, the minimap, undo history, keyboard moves, the hover card (eventHover) and a context menu in the product’s skin.

This example’s code decides

  • The physics of a firing: the ramp of each program, the hold, and natural cooling from the cone’s peak to 200 °C, set by each kiln’s thermal mass.
  • What a kiln can take: its cone, its programs and its volume; and what the studio needs: someone to load and unload between 08:00 and 20:00, and a day between a bisque and its next firing.
  • The energy ledger: every quarter hour of the month, the electricity at its tariff, gas and propane, the 22 kW contract, the week’s bill and the daily series fed to the minimap.
  • The commands: next clean start, cheapest start, one more hour of hold, fire now and back to the shelf, each recorded as one undo step; a bisque moved later takes its glaze firing along.
  • The curve cards, the shelf stickers, the kiln headers with what each kiln is doing at the studio clock, and the figures in the bars.

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: zoom levels with three header rows, rowHeaderColumns, the frozen energy row, separators, business hours, links built from the data, renderEvent, the areas of each card, the drag verdict, the context menu, and the callbacks where the studio’s rules refuse, fit or extend a firing.

/**
 * Cone Ten kiln board: four kilns over October, a frozen energy row on top, firings drawn as their
 * own temperature curves, and greenware dragged in from the shelf.
 *
 * Library: row-header columns written by onBeforeRowHeaderRender, a frozen summary row painted by
 * onBeforeCellRender, three time-header rows (day, open hours as header areas, hours) written by
 * onBeforeTimeHeaderRender, zoom levels from hours to the month, business-hours shading
 * (businessBeginsHour, businessEndsHour, cellsMarkBusiness), separators, the full crosshair,
 * events at exact times with React render slots (renderEvent, HTML fallback), time-anchored and
 * hover areas, external drag (SuperScheduler.Scheduler.makeDraggable, see ./shelf), drag and
 * resize steered frame by frame (onEventMoving, onEventResizing: refusals, the end fitted to the
 * kiln under the pointer), finish-to-start links, conflict highlighting, keyboard moves, undo
 * history, the event hover card and the drag card (super-scheduler/react-render), and a context
 * menu. Application: what a kiln can fire, the physics of a firing (ramp, hold, natural cooling),
 * staffed hours, bisque before glaze, the electricity tariff, the contract and the energy figures.
 * Those rules live in ./rules; ./board-paint and ./firing-card write the markup.
 */
import { useCallback, useEffect, useMemo, useRef, useState, 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 { format, plural } from '~/i18n/format'
import { DAY, HOUR, MINUTE, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { formatSpan } from '../kit/format'
import { defaultHints, type HoverFact } 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,
  ENERGY_ROW,
  firingLabel,
  isKilnRow,
  readFiringFields,
  toResources,
  type FiringFields,
  type KilnEvent,
} from './adapter'
import {
  LIMIT_SHARE,
  clock,
  coneHtml,
  coneLabel,
  createHeaderPainter,
  energyHeader,
  energyKey,
  energyPainter,
  kilnHtml,
  volumeHtml,
  when,
} from './board-paint'
import {
  FiringCard,
  cardHtml,
  cardSize,
  curvePaths,
  phaseOf,
  stepsHtml,
  useKilnHover,
  yOf,
  type CardParts,
  type KilnCardModel,
} from './firing-card'
import {
  CONE_PEAK,
  CONTRACT_KW,
  INITIAL_VIEW_START,
  KILNS,
  NOW,
  PLAN,
  PROGRAMS,
  SERVICES,
  SHELF,
  batchById,
  firingId,
  kilnById,
  type Firing,
  type FiringKind,
} from './model'
import {
  NOW_TICKS,
  cascadeOf,
  cheapestStart,
  extendHold,
  fireNow,
  firingCost,
  holdRefusal,
  isStaffed,
  kilnStateAt,
  moveRefusal,
  nextCleanStart,
  nextStaffed,
  ledgerWithout,
  overloadOf,
  overloadWith,
  partnerOf,
  placed,
  profileOf,
  snapHoldEnd,
  temperatureAt,
  unloadAt,
  earliestFinish,
  type Issue,
  type Ledger,
  type Placement,
  type Refusal,
} from './rules'
import type { ConeTenText, ZoomId } from './text'
import 'super-scheduler/styles.css'
import '../../scheduler/skin.css'

const SLUG = 'ceramics-kilns'
const STEP = 15 * MINUTE

/** What the board asks the demo to do (context menu, hover buttons, keyboard). */
export type FiringAction = 'details' | 'hold' | 'fire-now' | 'next' | 'cheapest' | 'shelf'

export interface ConeTenSchedulerProps {
  readonly events: KilnEvent[]
  /** The same firings as business objects, for rules that look at the others. */
  readonly firings: readonly Firing[]
  readonly ledger: Ledger
  readonly issues: readonly Issue[]
  readonly text: ConeTenText
  readonly common: DemoCommon
  /** 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
  /** The firing shown in the side sheet: it gets the selection frame. */
  readonly selected: string | null
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  /** `external`: the batch came from the shelf. */
  readonly onMoved: (id: string, external: boolean) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (id: string, message: string) => void
  /** A bisque moved later and its finish follows: one state change, one undo step. */
  readonly onCascade: (firing: Firing, placement: Placement, follower: Firing) => void
  readonly onAction: (action: FiringAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /** Narrow stages keep the kiln column only. */
  readonly narrow: boolean
}

/**
 * Three day-wide rows: the day, its open hours, its clock hours. The hours are areas of a day cell
 * (every hour, three or six, as the density allows), so the level-of-detail planner never has to
 * coarsen an hour row into days at the week's 8 px per hour.
 */
const HOURLY: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', height: BOARD.header.day },
  { groupBy: 'Day', height: BOARD.header.band },
  { groupBy: 'Day', height: BOARD.header.hour },
]

/** Hours to plan a load to the quarter, the week by default, the month to see the energy. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'hours', properties: { scale: 'Hour', cellWidth: 30, timeHeaders: HOURLY } },
  { id: 'week', properties: { scale: 'Hour', cellWidth: BOARD.hourWidth, timeHeaders: HOURLY } },
  {
    id: 'month',
    properties: {
      scale: 'Day',
      cellWidth: 40,
      timeHeaders: [
        { groupBy: 'Week', height: BOARD.header.day },
        { groupBy: 'Day', height: BOARD.header.band },
        { groupBy: 'Day', height: BOARD.header.hour, format: 'd' },
      ],
    },
  },
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week', 'month']

/** Pixels per hour at the control's current scale. */
function pxPerHour(control: SuperScheduler.Scheduler | SuperScheduler.SchedulerApi | null): number {
  if (control === null) return BOARD.hourWidth
  const width = control.cellWidth ?? BOARD.hourWidth
  return control.scale === 'Day' ? width / 24 : width
}

/** The big pills that appear over a booked firing (areas with visibility 'Hover'). */
function pill(
  label: string,
  tip: string,
  right: number,
  width: number,
  run: () => void,
  primary = false,
): SuperScheduler.AreaData {
  return {
    right,
    // At the foot of the cooling tail: the middle of the card stays free to grab and drag.
    bottom: 7,
    width,
    height: 24,
    visibility: 'Hover',
    action: 'None',
    cssClass: primary ? 'ck-pill ck-pill--primary' : 'ck-pill',
    html: `<span>${SuperScheduler.Util.escapeHtml(label)}</span>`,
    toolTip: tip,
    onClick: (args) => {
      args.preventDefault()
      run()
    },
  }
}

/** Rough width of a card's owner label (13 px Geist 900, uppercase) with its inset. */
function labelWidth(owner: string): number {
  return owner.length * 9.6 + 14
}

/**
 * True when the cone can be flagged on the peak: the owner reads in the sky over the ramp, so the
 * ramp must be wider than the label. Otherwise the card carries the cone next to the owner.
 */
function badgeFits(firing: Firing, owner: string, width: number): boolean {
  const profile = profileOf(firing)
  if (profile === null || width < 90) return false
  const rampPx = ((profile.peakAt - profile.start) / (profile.end - profile.start)) * width
  return rampPx >= labelWidth(owner)
}

/** The draft of a firing being dragged: the one on the board, or a shelf batch coming in. */
function draftOf(id: string, fields: FiringFields, firings: readonly Firing[]): Firing | null {
  const current = firings.find((candidate) => candidate.id === id)
  if (current !== undefined) return current
  if (fields.kind === 'service' || fields.batch === undefined) return null
  const kiln = KILNS.find((candidate) => candidate.kinds.includes(fields.kind as FiringKind))
  if (kiln === undefined) return null
  return {
    id,
    batch: fields.batch,
    kind: fields.kind,
    kiln: kiln.id,
    start: NOW,
    end: civil(unloadAt(fields.kind, kiln, NOW_TICKS, PROGRAMS[fields.kind].hold)),
  }
}

/**
 * Where a dragged firing would land: its load rounded to the quarter hour (a month cell keeps its
 * time of day), and its unload fitted to the program on the kiln under the pointer, with the hold
 * it had. The scheduler's shadow follows this geometry.
 */
function fit(
  firing: Firing,
  target: string,
  rawStart: number,
  dayCells: boolean,
): Placement | null {
  const kiln = kilnById(target)
  if (kiln === null) return null
  let start = Math.round(rawStart / STEP) * STEP
  if (dayCells) {
    const original = parseCivil(firing.start)
    start = start - (((start % DAY) + DAY) % DAY) + (((original % DAY) + DAY) % DAY)
  }
  return placed(firing, kiln, start)
}

/** A refusal as one complete sentence with the batch's and the kiln's numbers. */
export function refusalText(
  reason: Refusal,
  firing: Pick<Firing, 'batch' | 'kind'>,
  target: string,
  start: number,
  firings: readonly Firing[],
  text: ConeTenText,
  tag: string,
): string {
  const kiln = kilnById(target)
  const batch = batchById(firing.batch)
  // Temperatures and litres read without grouping: 1285 °C.
  const number = new Intl.NumberFormat(tag, { useGrouping: false })
  const cone = PROGRAMS[firing.kind].cone
  const earliest = earliestFinish(firing, firings)
  if (reason === 'kind') {
    if (firing.kind === 'raku') return text.rules.raku
    return kiln === null ? text.rules['not-a-kiln'] : text.rules.kind[kiln.id]
  }
  return format(text.rules[reason], {
    batch: batch === null ? firing.batch : firingLabel(firing, text),
    kiln: kiln === null ? '' : text.kilns[kiln.id],
    cone,
    peak: number.format(CONE_PEAK[cone]),
    max: kiln === null ? '' : kiln.maxCone,
    maxPeak: kiln === null ? '' : number.format(CONE_PEAK[kiln.maxCone]),
    volume: number.format(batch?.volume ?? 0),
    capacity: number.format(kiln?.volume ?? 0),
    time: when(reason === 'past' ? NOW_TICKS : start, tag),
    earliest: earliest === null ? '' : when(nextStaffed(earliest), tag),
  })
}

/** The status chip of a firing on the board, its flags first. */
function statusOf(
  firing: Firing,
  issues: readonly Issue[],
  text: ConeTenText,
): KilnCardModel['status'] {
  const own = issues.find((issue) => issue.firing === firing.id)
  if (own !== undefined) return { label: text.issues[own.kind], tone: 'danger' }
  const phase = phaseOf(firing, NOW_TICKS)
  if (phase === 'firing') return { label: text.status.firing, tone: 'warning' }
  if (phase === 'cooling') return { label: text.status.cooling, tone: 'success' }
  if (phase === 'fired') return { label: text.status.fired }
  return { label: text.status.booked, tone: 'success' }
}

/** What the hover card and the sheet say about a firing. */
export function firingFacts(
  firing: Firing,
  issues: readonly Issue[],
  ledger: Ledger,
  text: ConeTenText,
  tag: string,
): { facts: HoverFact[]; rule: KilnCardModel['rule'] } {
  const profile = profileOf(firing)
  const batch = batchById(firing.batch)
  const number = new Intl.NumberFormat(tag)
  const degrees = new Intl.NumberFormat(tag, { useGrouping: false })
  const money = new Intl.NumberFormat(tag, {
    style: 'currency',
    currency: 'EUR',
    maximumFractionDigits: 0,
  })
  if (profile === null) return { facts: [], rule: undefined }
  const cost = firingCost(firing)
  const own = new Set(
    issues.filter((issue) => issue.firing === firing.id).map((issue) => issue.kind),
  )
  const facts: HoverFact[] = [
    {
      label: text.fields.batch,
      value:
        batch === null
          ? firing.batch
          : `${text.pieces[batch.piece]} · ${plural(text.count, batch.count, tag)} · ${number.format(batch.volume)} L`,
    },
    { label: text.fields.load, value: when(profile.start, tag) },
    {
      label: text.fields.peak,
      value: format(text.values.peak, {
        temperature: degrees.format(profile.peak),
        time: when(profile.peakAt, tag),
      }),
    },
    {
      label: text.fields.unload,
      value: when(profile.end, tag),
      alert: own.has('unload-closed'),
    },
    {
      label: text.fields.energy,
      value:
        profile.kiln.energy === 'electric'
          ? format(text.values.electric, {
              kwh: number.format(Math.round(cost.kwh)),
              euros: money.format(cost.euros),
            })
          : format(text.values.fuel, {
              kwh: number.format(Math.round(cost.kwh)),
              euros: money.format(cost.euros),
              fuel: text.fuels[profile.kiln.energy],
            }),
      alert: own.has('power'),
    },
  ]
  let rule: KilnCardModel['rule']
  if (own.has('unload-closed'))
    rule = {
      state: 'blocked',
      text: format(text.card.unloadRule, {
        time: when(profile.end, tag),
        next: when(nextStaffed(profile.end), tag),
      }),
    }
  else if (own.has('power')) {
    const overload = overloadOf(firing, ledger)
    rule = {
      state: 'blocked',
      text: format(text.card.powerRule, {
        kw: number.format(Math.round(overload?.kw ?? 0)),
        contract: number.format(CONTRACT_KW),
      }),
    }
  } else if (profile.end <= NOW_TICKS) rule = { state: 'ok', text: text.card.firedRule }
  else if (profile.start < NOW_TICKS) rule = { state: 'warn', text: text.card.lockedRule }
  else rule = { state: 'ok', text: text.card.okRule }
  return { facts, rule }
}

/** Ramp, hold and cooling of a firing, for the hover card and the sheet. */
export function phasesOf(firing: Firing, text: ConeTenText): KilnCardModel['phases'] {
  const profile = profileOf(firing)
  if (profile === null) return undefined
  return [
    { id: 'ramp', label: text.fields.ramp, minutes: profile.ramp },
    { id: 'hold', label: text.fields.hold, minutes: profile.hold },
    ...(profile.cool > 0
      ? [{ id: 'cool' as const, label: text.fields.cooling, minutes: profile.cool }]
      : []),
  ]
}

/** Menu and sheet labels of the commands that move a firing: where it would go, or why not. */
export function commandLabels(
  firing: Firing,
  firings: readonly Firing[],
  text: ConeTenText,
  tag: string,
): {
  readonly hold: { label: string; enabled: boolean }
  readonly fireNow: { label: string; enabled: boolean }
  readonly next: { label: string; enabled: boolean }
  readonly cheapest: { label: string; enabled: boolean }
  readonly shelf: { label: string; enabled: boolean }
} {
  const locked = parseCivil(firing.start) < NOW_TICKS
  const hold = extendHold(firing.id, firings)
  const now = fireNow(firing.id, firings)
  const next = locked ? null : nextCleanStart(firing.id, firings)
  const kiln = kilnById(firing.kiln)
  const cheapest = locked || kiln?.energy !== 'electric' ? null : cheapestStart(firing.id, firings)
  const money = new Intl.NumberFormat(tag, {
    style: 'currency',
    currency: 'EUR',
    maximumFractionDigits: 0,
  })
  const follower = firing.kind === 'bisque' ? partnerOf(firing, firings) : null
  return {
    hold: {
      label:
        typeof hold === 'string'
          ? hold === 'max-hold'
            ? text.rules.maxHold
            : hold === 'kiln-busy'
              ? text.rules.kilnBusy
              : text.actions.hold
          : text.actions.hold,
      enabled: typeof hold !== 'string',
    },
    fireNow: { label: text.actions.fireNow, enabled: now !== null },
    next:
      next === null
        ? { label: text.actions.noNext, enabled: false }
        : next.start === firing.start
          ? { label: text.actions.atNext, enabled: false }
          : {
              label: format(text.actions.next, { time: when(parseCivil(next.start), tag) }),
              enabled: true,
            },
    cheapest:
      cheapest === null
        ? { label: text.actions.noCheapest, enabled: false }
        : cheapest.start === firing.start
          ? { label: text.actions.atCheapest, enabled: false }
          : {
              label: format(text.actions.cheapest, {
                time: when(parseCivil(cheapest.start), tag),
                euros: money.format(firingCost({ ...firing, ...cheapest }).euros),
              }),
              enabled: true,
            },
    shelf: {
      label: follower !== null ? text.actions.shelfBlocked : text.actions.shelf,
      enabled: !locked && follower === null,
    },
  }
}

/** The parts of each card for one set of flags, cached per firing state and width. */
function cardPartsFor(
  flagged: ReadonlyMap<string, ReadonlySet<Issue['kind']>>,
  text: ConeTenText,
  tag: string,
): (firing: Firing, width: number) => CardParts {
  const cache = new Map<string, CardParts>()
  return (firing, width) => {
    const size = cardSize(width)
    const flags = flagged.get(firing.id)
    const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
    const cached = cache.get(key)
    if (cached !== undefined) return cached
    const batch = batchById(firing.batch)
    const owner = batch === null ? firing.batch : text.owners[batch.owner]
    const hours = (parseCivil(firing.end) - parseCivil(firing.start)) / HOUR
    const parts: CardParts = {
      kind: firing.kind,
      owner,
      coneInline: size !== 'tiny' && !badgeFits(firing, owner, width),
      detail:
        batch === null
          ? ''
          : `${text.pieces[batch.piece]} · ${new Intl.NumberFormat(tag).format(batch.count)}`,
      cone: coneLabel(PROGRAMS[firing.kind].cone),
      phase: phaseOf(firing, NOW_TICKS),
      size,
      flagged: flags !== undefined,
      paths: curvePaths(firing, hours > 0 ? width / hours : BOARD.hourWidth),
    }
    cache.set(key, parts)
    return parts
  }
}

export function ConeTenScheduler(props: ConeTenSchedulerProps) {
  const {
    events,
    firings,
    ledger,
    issues,
    text,
    common,
    tag,
    schedulerLocale,
    initialZoom,
    selected,
    history,
    controlRef,
    narrow,
  } = props
  const {
    onEventsChange,
    onSelect,
    onMoved,
    onResized,
    onRefused,
    onCascade,
    onAction,
    onZoomLevel,
  } = props

  const resources = useMemo(() => toResources(text), [text])
  const board = useRef<HTMLDivElement>(null)
  // Hover buttons call the latest handler without rebuilding every card's areas.
  const action = useRef(onAction)
  useEffect(() => {
    action.current = onAction
  }, [onAction])

  // The level on screen: a settled zoom re-labels headers and resizes cards and pills.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const density = level === 'hours' ? 30 : level === 'month' ? 40 / 24 : BOARD.hourWidth

  // A refused drop (cone, program, volume, the clock, staffed hours, glaze too early) is said on
  // release; the library refuses overlaps itself.
  const refusal = useDropRefusal(onRefused)

  const flagged = useMemo(() => {
    const map = new Map<string, Set<Issue['kind']>>()
    for (const issue of issues) {
      const set = map.get(issue.firing) ?? new Set()
      set.add(issue.kind)
      map.set(issue.firing, set)
    }
    return map
  }, [issues])

  // Links come from the business model: a batch's bisque finishes before its finish firing loads.
  const links = useMemo<SuperScheduler.LinkData[]>(
    () =>
      firings.flatMap((firing): SuperScheduler.LinkData[] => {
        if (firing.kind !== 'bisque') return []
        const next = partnerOf(firing, firings)
        if (next === null) return []
        const past = parseCivil(next.end) <= NOW_TICKS
        return [
          {
            id: `${firing.id}->${next.id}`,
            from: firing.id,
            to: next.id,
            type: 'FinishToStart',
            width: 2,
            style: 'dashed',
            cssClass: past ? 'ck-link ck-link--past' : 'ck-link',
          },
        ]
      }),
    [firings],
  )

  // Hover card: the batch, the program's stages, the energy and the rule that applies.
  const card = useCallback(
    (event: SuperScheduler.Event): KilnCardModel | null => {
      const id = String(event.id())
      // The firing in the side sheet needs no card on top of it.
      if (id === selected) return null
      const firing = firings.find((candidate) => candidate.id === id)
      if (firing === undefined) {
        const service = SERVICES.find((candidate) => candidate.id === id)
        if (service === undefined) return null
        return {
          id,
          kind: 'service',
          eyebrow: `${text.kinds.service} · ${text.kilns[service.kiln]}`,
          title: text.service.title,
          status: { label: text.status.service },
          facts: [
            { label: text.fields.load, value: when(parseCivil(service.start), tag) },
            { label: text.fields.unload, value: when(parseCivil(service.end), tag) },
          ],
          rule: { state: 'blocked', text: text.card.serviceRule },
        }
      }
      const { facts, rule } = firingFacts(firing, issues, ledger, text, tag)
      const program = PROGRAMS[firing.kind]
      return {
        id,
        kind: firing.kind,
        eyebrow: `${text.kinds[firing.kind]} · ${format(text.cone, { cone: program.cone })} · ${text.kilns[firing.kiln]}`,
        title: firingLabel(firing, text),
        status: statusOf(firing, issues, text),
        phases: phasesOf(firing, text),
        phasesLabel: text.card.phases,
        facts,
        rule,
        hints: defaultHints(common, { move: parseCivil(firing.start) >= NOW_TICKS }),
      }
    },
    [firings, issues, ledger, selected, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useKilnHover(card, tag)

  // Drag verdict: the studio's refusal, or what the drop would mean: load, peak and unload of the
  // program on the kiln under the pointer, its cost, and the warnings (nobody to unload, the
  // contract, the glaze firing that follows).
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const draftFor = (info: SuperScheduler.DragCardInfo): Firing | null => {
      const id = info.eventId === null ? null : String(info.eventId)
      if (id === null) return null
      const current = firings.find((candidate) => candidate.id === id)
      if (current !== undefined) return current
      const shelf = SHELF.find((entry) => firingId(entry.batch, entry.kind) === id)
      return shelf === undefined
        ? null
        : draftOf(id, { kind: shelf.kind, batch: shelf.batch }, firings)
    }
    const proposed = (info: SuperScheduler.DragCardInfo): Firing | null => {
      const draft = draftFor(info)
      if (draft === null || info.resource === null || !isKilnRow(info.resource)) return null
      return {
        ...draft,
        kiln: info.resource,
        start: civil(info.start.getTime()),
        end: civil(info.end.getTime()),
      }
    }
    // The load of every other firing, built once per dragged firing and plan.
    const ledgers = new Map<string, Ledger>()
    const othersOf = (id: string) => {
      let others = ledgers.get(id)
      if (others === undefined) {
        others = ledgerWithout(id, firings)
        ledgers.set(id, others)
      }
      return others
    }
    const number = new Intl.NumberFormat(tag, { useGrouping: false })
    const money = new Intl.NumberFormat(tag, {
      style: 'currency',
      currency: 'EUR',
      maximumFractionDigits: 0,
    })
    const base = createDragCard({
      tag,
      common,
      rowName: (id) => (isKilnRow(id) ? text.kilns[id] : null),
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const profile = profileOf(next)
        if (profile === null) return null
        if (!isStaffed(profile.end)) {
          const open = nextStaffed(profile.end)
          return format(text.verdict.unloadClosed, {
            time: when(profile.end, tag),
            next: when(open, tag),
            wait: formatSpan(open - profile.end, tag),
          })
        }
        if (profile.kiln.energy === 'electric') {
          const overload = overloadWith(next, othersOf(next.id))
          if (overload !== null)
            return format(text.verdict.power, {
              kw: number.format(Math.round(overload.kw)),
              contract: number.format(CONTRACT_KW),
            })
        }
        if (info.mode === 'move' && next.kind === 'bisque' && !info.external) {
          const current = firings.find((candidate) => candidate.id === next.id)
          if (current !== undefined) {
            const follower = cascadeOf(
              current,
              { kiln: next.kiln, start: next.start, end: next.end },
              firings,
            )
            if (follower !== null && follower !== 'blocked')
              return format(text.verdict.cascade, {
                batch: firingLabel(follower, text),
                time: when(parseCivil(follower.start), tag),
              })
          }
        }
        if (info.mode === 'resize')
          return format(text.verdict.hold, { hold: formatSpan(profile.hold * MINUTE, tag) })
        return null
      },
    })
    return {
      ...base,
      html: (info) => {
        const html = base.html?.(info) ?? null
        if (html === null || info.forbidden || info.overlapping) return html
        const next = proposed(info)
        const profile = next === null ? null : profileOf(next)
        if (next === null || profile === null) return html
        const cost = firingCost(next)
        const steps = stepsHtml({
          load: `${text.verdict.load} ${when(profile.start, tag)}`,
          peak: `${text.verdict.peak} ${number.format(profile.peak)}°`,
          unload: `${text.verdict.unload} ${when(profile.end, tag)}`,
          unloadOk: isStaffed(profile.end),
          cost: format(text.verdict.cost, { euros: money.format(cost.euros) }),
        })
        return `${html.slice(0, -'</div>'.length)}${steps}</div>`
      },
    }
  }, [firings, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real slot.
  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 firing = firings.find((candidate) => candidate.id === id)
          if (firing === undefined) return []
          const labels = commandLabels(firing, firings, text, tag)
          const issueOf = issues.find((issue) => issue.firing === id)
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (parseCivil(firing.start) < NOW_TICKS) return entries
          entries.push(
            {
              label: labels.next.label,
              icon: 'slot',
              primary: issueOf?.kind === 'unload-closed',
              disabled: !labels.next.enabled,
              run: () => onAction('next', id),
            },
            {
              label: labels.cheapest.label,
              icon: 'after',
              primary: issueOf?.kind === 'power',
              disabled: !labels.cheapest.enabled,
              run: () => onAction('cheapest', id),
            },
            {
              label: labels.hold.label,
              icon: 'pending',
              disabled: !labels.hold.enabled,
              run: () => onAction('hold', id),
            },
            {
              label: labels.fireNow.label,
              icon: 'confirm',
              disabled: !labels.fireNow.enabled,
              run: () => onAction('fire-now', id),
            },
            'separator',
            {
              label: labels.shelf.label,
              icon: 'layers',
              disabled: !labels.shelf.enabled,
              run: () => onAction('shelf', id),
            },
          )
          return entries
        },
      }),
    [firings, issues, common, text, tag, onAction, suppressHover],
  )

  // Time headers, written once per cell and level.
  const headerPaint = useMemo(() => createHeaderPainter(text, tag), [text, tag])
  // The energy row, from the ledger in state, cached per cell.
  const paintEnergy = useMemo(() => energyPainter(ledger, text, tag), [ledger, text, tag])

  // Cards: their parts, cached per firing state and width.
  const partsOf = useMemo(() => cardPartsFor(flagged, text, tag), [flagged, text, tag])

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const byId = new Map(firings.map((firing) => [firing.id, firing]))
    const services = new Map(SERVICES.map((service) => [service.id, service]))
    const states = new Map(
      KILNS.map((kiln) => [kiln.id, kilnStateAt(kiln.id, firings, SERVICES, NOW_TICKS)]),
    )
    // Temperatures read without grouping: 1285 °C.
    const number = new Intl.NumberFormat(tag, { useGrouping: false })
    const areaCache = new Map<string, SuperScheduler.AreaData[]>()

    const dayCells = () => controlRef.current?.scale === 'Day'
    const messageFor = (reason: Refusal, firing: Firing, target: string, start: number) =>
      refusalText(reason, firing, target, start, firings, text, tag)

    /** Areas of a card: peak badge, temperature now, flags, and the hover pills of a booked firing. */
    const areasOf = (firing: Firing, width: number, phase: string): SuperScheduler.AreaData[] => {
      const flags = flagged.get(firing.id)
      const key = `${firing.id}|${firing.kiln}|${firing.start}|${firing.end}|${Math.round(width / 8)}|${flags === undefined ? '' : [...flags].join(',')}`
      const cached = areaCache.get(key)
      if (cached !== undefined) return cached
      const profile = profileOf(firing)
      const areas: SuperScheduler.AreaData[] = []
      if (profile === null) return areas
      // The cone, flagged on the peak (right of it: the owner reads in the sky over the ramp).
      const batch = batchById(firing.batch)
      if (badgeFits(firing, batch === null ? firing.batch : text.owners[batch.owner], width))
        areas.push({
          start: civil(profile.peakAt),
          offsetX: 3,
          top: 3,
          width: 38,
          height: 17,
          cssClass: 'ck-peak',
          html: `<span>${escape(coneLabel(PROGRAMS[firing.kind].cone))}</span>`,
          toolTip: `${number.format(profile.peak)} °C · ${when(profile.peakAt, tag)}`,
        })
      // The temperature at the studio clock, on the curve, for a firing in the kiln now.
      if ((phase === 'firing' || phase === 'cooling') && width >= 54) {
        const temperature = Math.round(temperatureAt(firing, NOW_TICKS))
        areas.push({
          start: NOW,
          offsetX: -22,
          width: 44,
          height: 18,
          top: `calc(${Math.round(yOf(temperature) * 10) / 10}% - 9px)`,
          cssClass: 'ck-now-temp',
          html: `<span>${escape(number.format(temperature))}°</span>`,
        })
      }
      // Over the contract: hazard stripes under the stretch of the draw that trips the breaker.
      if (flags?.has('power') === true) {
        const overload = overloadOf(firing, ledger)
        if (overload !== null)
          areas.push({
            start: civil(Math.max(overload.from, profile.start)),
            end: civil(Math.min(overload.to, profile.holdEnd)),
            bottom: 0,
            height: 9,
            cssClass: 'ck-hazard',
            toolTip: text.issues.power,
          })
      }
      // Nobody to unload: a sticker on the cold end of the card.
      if (flags?.has('unload-closed') === true && width >= 120)
        areas.push({
          right: 7,
          // Mid-height, in the sky over the cold end of the curve.
          top: 'calc(50% - 16px)',
          // Sized to its text (9.5 px Geist 900, uppercase), inside the card.
          width: Math.min(width - 8, format(text.card.flag, { time: '00:00' }).length * 6.9 + 18),
          height: 22,
          cssClass: 'ck-flag',
          html: `<span>${escape(format(text.card.flag, { time: clock(profile.end, tag) }))}</span>`,
          toolTip: format(text.card.unloadRule, {
            time: when(profile.end, tag),
            next: when(nextStaffed(profile.end), tag),
          }),
        })
      // Big pills over a booked firing: open it, one more hour of hold, fire it now.
      if (phase === 'booked') {
        const id = firing.id
        if (width >= 110)
          areas.push(
            pill(text.actions.details, common.kit.openDetails, 5, 52, () =>
              action.current('details', id),
            ),
          )
        if (width >= 176)
          areas.push(
            pill(text.actions.holdShort, text.actions.hold, 62, 46, () =>
              action.current('hold', id),
            ),
          )
        if (width >= 262)
          areas.push(
            pill(
              text.actions.fireNowShort,
              text.actions.fireNow,
              113,
              84,
              () => action.current('fire-now', id),
              true,
            ),
          )
      }
      areaCache.set(key, areas)
      return areas
    }

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: PLAN.start,
      days: PLAN.days,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Exact times: a load at 16:30 stays at 16:30; onEventMoving rounds to the quarter hour.
      useEventBoxes: 'Never',
      height: '100%',
      // The studio is staffed 08:00 to 20:00 every day: the rest is shaded as closed.
      businessBeginsHour: 8,
      businessEndsHour: 20,
      businessWeekends: true,
      cellsMarkBusiness: true,
      rowHeaderColumns: narrow
        ? [{ text: text.columns.kiln, width: BOARD.narrowColumn, nonresizable: true }]
        : [
            { text: text.columns.kiln, width: BOARD.kilnColumn, nonresizable: true },
            { text: text.columns.cone, width: BOARD.coneColumn, nonresizable: true },
            { text: text.columns.volume, width: BOARD.volumeColumn, nonresizable: true },
          ],
      rowHeaderWidth: narrow ? BOARD.narrowColumn : BOARD.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: narrow ? BOARD.narrowEventHeight : BOARD.eventHeight,
      rowMarginTop: BOARD.rowMarginTop,
      rowMarginBottom: BOARD.rowMarginBottom,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      // The curve is drawn to the time axis: no label may slide when the viewport cuts a firing.
      floatingEvents: false,
      linkShape: 'RightAngled',
      linkLayer: 'Below',
      crosshairType: 'Full',
      // Midnights as heavy rules, and the studio clock above the cards.
      separators: [
        ...Array.from({ length: PLAN.days + 1 }, (_, index) => ({
          location: civil(parseCivil(PLAN.start) + index * DAY),
          layer: 'BelowEvents' as const,
          width: 2,
          cssClass: 'ck-sep-day',
        })),
        {
          location: NOW,
          layer: 'AboveEvents',
          width: 3,
          cssClass: 'ck-sep-now',
          toolTip: format(text.header.nowTip, { time: when(NOW_TICKS, tag) }),
        },
      ],
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeCornerRender: (args) => {
        args.html = `<span class="ck-corner" aria-hidden="true"><b>${escape(text.brand)}</b><span>${escape(text.header.corner)}</span></span>`
      },

      onBeforeTimeHeaderRender: (args) => {
        headerPaint(args.header, pxPerHour(args.control))
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const [first, second, third] = row.columns
        if (first === undefined) return
        if (row.id === ENERGY_ROW) {
          row.cssClass = 'ck-row ck-row--energy'
          first.html = energyHeader(text, level === 'month')
          if (second !== undefined) second.html = energyKey(text, tag, 'peak')
          if (third !== undefined) third.html = energyKey(text, tag, 'contract')
          return
        }
        const kiln = isKilnRow(row.id) ? kilnById(row.id) : null
        if (kiln === null) return
        const status = states.get(kiln.id) ?? { state: 'idle' as const, temperature: 20 }
        row.cssClass = `ck-row ck-row--${kiln.id}`
        first.html = kilnHtml(kiln, status, text, tag, narrow)
        if (second !== undefined) second.html = coneHtml(kiln, tag)
        if (third !== undefined) third.html = volumeHtml(kiln, tag)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        if (cell.resource !== ENERGY_ROW) return
        const painted = paintEnergy(
          cell.start.getTime(),
          cell.end.getTime(),
          args.control.cellWidth ?? BOARD.hourWidth,
        )
        cell.properties.html = painted.html
        cell.properties.cssClass = painted.css
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const id = String(data.id)
        const service = services.get(id)
        const width = ((data.end.getTime() - data.start.getTime()) / HOUR) * pxPerHour(args.control)
        if (service !== undefined) {
          data.html = cardHtml({
            kind: 'service',
            owner: text.service.title,
            detail: '',
            cone: '',
            coneInline: false,
            phase: 'booked',
            size: cardSize(width),
            flagged: false,
            paths: null,
          })
          data.cssClass = 'ck-ev ck-ev--service'
          data.ariaLabel = `${text.service.title}, ${text.kilns[service.kiln]}`
          return
        }
        const firing = byId.get(id)
        if (firing === undefined) return
        const parts = partsOf(firing, width)
        data.html = cardHtml(parts)
        const classes = ['ck-ev', `ck-ev--${firing.kind}`, `ck-ev--${parts.phase}`]
        const flags = flagged.get(id)
        if (flags !== undefined) classes.push('ck-ev--flag')
        if (id === selected) classes.push('ck-ev--selected')
        data.cssClass = classes.join(' ')
        data.areas = areasOf(firing, width, parts.phase)
        const profile = profileOf(firing)
        data.ariaLabel =
          `${String(data.text)}, ${text.kinds[firing.kind]}, ${format(text.cone, { cone: PROGRAMS[firing.kind].cone })}` +
          (profile === null
            ? ''
            : `, ${text.fields.load} ${when(profile.start, tag)}, ${text.fields.unload} ${when(profile.end, tag)}`) +
          (flags === undefined ? '' : `, ${[...flags].map((kind) => text.issues[kind]).join(', ')}`)
      },

      // Every frame of a drag (on the board or in from the shelf): round the load, fit the unload
      // to the kiln under the pointer, and refuse what the studio does not allow.
      onEventMoving: (args) => {
        const fields = readFiringFields(args.e.data)
        if (fields === null || fields.kind === 'service') return
        const id = String(args.e.id())
        const draft = draftOf(id, fields, firings)
        if (draft === null) return
        const target = String(args.resource)
        const placement = fit(draft, target, args.start.getTime(), dayCells())
        if (placement !== null) {
          args.start = new SuperScheduler.Date(parseCivil(placement.start))
          args.end = new SuperScheduler.Date(parseCivil(placement.end))
        }
        const start = args.start.getTime()
        const reason = moveRefusal(draft, target, start, firings)
        if (reason !== null) {
          const message = messageFor(reason, draft, target, start)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        refusal.track(
          id,
          args.conflicts[0] === undefined
            ? null
            : format(common.kit.overlaps, { name: args.conflicts[0].text() }),
        )
      },

      // Final check before the commit (also keyboard moves): the same rules, and a bisque moved
      // later takes its finish firing along in one step.
      onEventMove: (args) => {
        const fields = readFiringFields(args.e.data)
        if (fields === null || fields.kind === 'service') return
        const id = String(args.e.id())
        const draft = draftOf(id, fields, firings)
        if (draft === null) return
        const target = String(args.newResource)
        const placement = fit(draft, target, args.newStart.getTime(), dayCells())
        const start = placement === null ? args.newStart.getTime() : parseCivil(placement.start)
        const reason = moveRefusal(draft, target, start, firings)
        if (reason !== null || placement === null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, messageFor(reason ?? 'not-a-kiln', draft, target, start))
          return
        }
        args.newStart = new SuperScheduler.Date(parseCivil(placement.start))
        args.newEnd = new SuperScheduler.Date(parseCivil(placement.end))
        if (args.external) return
        const follower = cascadeOf(draft, placement, firings)
        if (follower === 'blocked') {
          args.preventDefault()
          refusal.clear()
          onRefused(id, text.rules.cascade)
        } else if (follower !== null) {
          args.preventDefault()
          refusal.clear()
          onCascade(draft, placement, follower)
        }
      },

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

      // The end edge changes the hold; cooling is the kiln's and cannot be shortened, and the ramp
      // is the program's: the start edge does not move.
      onEventResizing: (args) => {
        const id = String(args.e.id())
        const firing = byId.get(id)
        if (firing === undefined) return
        if (args.what === 'start') {
          args.allowed = false
          args.message = text.rules.ramp
          refusal.track(id, text.rules.ramp)
          return
        }
        const end = snapHoldEnd(firing, args.end.getTime())
        args.end = new SuperScheduler.Date(end)
        const problem = holdRefusal(firing, end)
        if (problem === null) {
          refusal.track(id, null)
          return
        }
        const profile = profileOf(firing)
        const message =
          problem === 'short'
            ? format(text.rules.short, {
                peak: number.format(profile?.peak ?? 0),
                kiln: text.kilns[firing.kiln],
                cool: formatSpan((profile?.cool ?? 0) * MINUTE, tag),
                time: when(
                  parseCivil(firing.start) +
                    ((profile?.ramp ?? 0) + PROGRAMS[firing.kind].minHold + (profile?.cool ?? 0)) *
                      MINUTE,
                  tag,
                ),
              })
            : format(text.rules.long, {
                max: formatSpan(PROGRAMS[firing.kind].maxHold * MINUTE, tag),
              })
        args.allowed = false
        args.message = message
        refusal.track(id, message)
      },

      onEventResize: (args) => {
        const id = String(args.e.id())
        const firing = byId.get(id)
        if (firing === undefined) return
        const end = snapHoldEnd(firing, args.newEnd.getTime())
        if (args.what === 'start' || holdRefusal(firing, end) !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, args.what === 'start' ? text.rules.ramp : text.rules.maxHold)
          return
        }
        args.newStart = new SuperScheduler.Date(parseCivil(firing.start))
        args.newEnd = new SuperScheduler.Date(end)
      },

      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()))
      },

      // A click (or Enter) docks the firing into the side sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        onSelect(String(args.e.id()))
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    firings,
    ledger,
    flagged,
    selected,
    narrow,
    level,
    history,
    controlRef,
    headerPaint,
    paintEnergy,
    partsOf,
    refusal,
    closeHover,
    common,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    onEventsChange,
    onMoved,
    onResized,
    onRefused,
    onCascade,
    onSelect,
    onZoomLevel,
  ])

  // React cards: the same parts as the HTML fallback, the curve redrawn by React.
  const renderEvent = useMemo<NonNullable<SchedulerRenderProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const firing = firings.find((candidate) => candidate.id === String(e.id()))
        if (firing === undefined) {
          const service = SERVICES.find((candidate) => candidate.id === String(e.id()))
          return service === undefined ? (
            e.text()
          ) : (
            <FiringCard
              parts={{
                kind: 'service',
                owner: text.service.title,
                detail: '',
                cone: '',
                coneInline: false,
                phase: 'booked',
                size: cardSize(width),
                flagged: false,
                paths: null,
              }}
            />
          )
        }
        const actual = ((data.end.getTime() - data.start.getTime()) / HOUR) * density
        return <FiringCard parts={partsOf(firing, Math.max(width, actual))} />
      },
    [firings, partsOf, density, 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 contract and the daily budget as lines in the energy row: CSSOM, from the model.
  useEffect(() => {
    const element = board.current
    if (element === null) return
    element.style.setProperty('--ck-limit-hour', `${Math.round(LIMIT_SHARE.hour * 1000) / 10}%`)
    element.style.setProperty('--ck-limit-day', `${Math.round(LIMIT_SHARE.day * 1000) / 10}%`)
  }, [])

  return (
    <div ref={board} className="ck-board-host h-full [&>div]:h-full">
      <SuperSchedulerComponent
        {...config}
        resources={resources}
        events={events}
        links={links}
        renderEvent={renderEvent}
        eventHover={eventHover}
        dragCard={dragCard}
        contextMenu={contextMenu}
      />
    </div>
  )
}

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