Saltar al contenido

Planificación de hornos cerámicos

Las piezas de cono 10 de una socia están listas. Cuécelas en el único horno que puede, cuando alguien pueda descargarlas, sin reventar el presupuesto de energía.

  • Taller de cerámica
  • Cone Ten
  • Arrastrar desde fuera
  • Slots de renderizado React
  • Arrastrar y redimensionar
  • Reglas de movimiento
  • Dependencias
  • Áreas activas
  • Filas de resumen
  • Minimapa
  • Temas
  • Demo con datos sintéticos

Misión

0 de 3 pasos hechos

Miércoles, 10:15. La vajilla de gres de Inés ya está bizcochada y esmaltada: necesita cono 10, en reducción.

  1. Carga la vajilla de cono 10 de Inés en Dragon, el horno de gas. — Pendiente

    Arrastra la pegatina INÉS del estante a la fila de Dragon, después de la cocción que se está enfriando. Pip y Bess la rechazan: llegan a cono 6 y cono 8. O pulsa CARGAR en la pegatina.

  2. Asegúrate de que haya alguien cuando se pueda descargar. — Pendiente

    Cargada esta tarde, se enfría en plena noche. Arrástrala al jueves a las 08:00, o haz clic derecho y elige Próximo inicio limpio.

  3. Devuelve la semana a su presupuesto de energía de 590 €. — Pendiente

    El bizcocho de la clase del miércoles en Bess sube en plena punta de 17:00 a 21:00, mientras Pip también sube. Arrástralo al jueves a las 08:00, o haz clic derecho y elige Inicio más barato.

Unos 30 segundos. Los pasos se marcan a medida que los completas.

Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

Cone Ten
PipC6 · 85 LBessC8 · 220 LDragonC11 · 700 LEmberC06 · 60 LKaiTazas · 18NoorCuencos · 12NoorCuencos · 12MargitAzulejos · 40LiorJarras · 8LiorJarras · 8OluJarrones · 6Clase mié.Cuencos · 32AdeFuentes · 5SolVasos · 20RuthTeteras · 7Clase sáb.Platos · 26Revisión de quemadoresTiendaChawan · 8PiaJarrones · 4MeiCubiletes · 6TiendaCubiletes · 107031626171033300017181192035117003511910181870003511700ElectricidadkWh por horamiércoles, 21 de octubrejueves, 22 de octubreviernes, 23 de octubresábado, 24 de octubredomingo, 25 de octubrelunes, 26 de octubremartes, 27 de octubremiércoles, 28 de octubre0004081216200004081216200004081216200004081216200004081216200004081216200004081216200004

Vista previa del primer fotogramaEn vivo: el motor Pro real, funcionando en tu navegadorUnos 30 segundos. Los pasos se marcan a medida que los completas. Teclado: enfoca la rejilla y usa las flechas, Intro y Alt+flechas para mover.

La situación.

Cone Ten es un taller cerámico compartido y ficticio con cuatro hornos: dos eléctricos para bizcocho y esmalte de cono 6, un horno de gas de vagoneta para reducción a cono 10 y un tambor de raku. Los socios dejan sus piezas en crudo en el estante y el taller las cuece. Una cocción no es un bloque de tiempo sino una curva: el programa sube el horno hasta su cono, lo mantiene ahí y después el ladrillo se enfría solo hasta que el horno se puede abrir por debajo de 200 °C.

Esa curva es lo que tiene que ver quien planifica. El enfriamiento depende de la masa del horno y nadie puede acortarlo; alguien tiene que estar para cargar y para descargar; un bizcocho tiene que salir un día antes de su cocción de esmalte; y la electricidad cuesta dos veces y media más entre las 17:00 y las 21:00, con una potencia contratada que hace saltar el interruptor general si los dos hornos eléctricos suben a la vez. Este tablero dibuja cada cocción como su propia curva de temperatura, rechaza lo que los hornos no pueden hacer y tiene la factura de energía siempre a la vista.

Qué vas a hacer

Cargarás un lote de cono 10 en el horno de gas, lo moverás para que alguien pueda descargarlo y devolverás la semana a su presupuesto de energía sacando un bizcocho de la tarifa punta.

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

SuperScheduler aporta

  • Contenido de los eventos con React render slots (renderEvent), con el mismo marcado como respaldo HTML: cada cocción dibuja su curva de temperatura sobre el intervalo exacto del evento, así que el pico y el final del enfriamiento caen bajo su propia hora en la cabecera.
  • Arrastre externo (SuperScheduler.Scheduler.makeDraggable): los lotes pasan del estante a un horno; onEventMoving ajusta la sombra al programa del horno bajo el puntero y rechaza, con args.message, lo que ese horno no puede cocer.
  • Movimientos y redimensionados guiados fotograma a fotograma: onEventMoving y onEventResizing redondean la carga al cuarto de hora, dejan que el borde final cambie solo el mantenimiento y se niegan a acortar el enfriamiento; la tarjeta de arrastre (dragCard.html) muestra carga, pico, descarga y coste.
  • Áreas ancladas al tiempo (el cono sobre su pico, la temperatura a la hora del taller, rayas de peligro sobre una sobrecarga, una bandera en una descarga sin nadie) y áreas al pasar el ratón (visibility: 'Hover') como grandes píldoras.
  • Una fila resumen fija pintada por onBeforeCellRender, tres filas de cabecera escritas por onBeforeTimeHeaderRender (el día, el horario del taller como áreas de cabecera, la hora), sombreado de horas no laborables, separadores a medianoche y a la hora del taller, y la cruz completa.
  • Enlaces de fin a inicio, niveles de zoom de las horas al mes, el minimapa, historial para deshacer, movimientos con teclado, la tarjeta flotante (eventHover) y un menú contextual con la piel del producto.

El código de este ejemplo decide

  • La física de una cocción: la subida de cada programa, el mantenimiento y el enfriamiento natural desde el pico del cono hasta 200 °C, que marca la masa térmica de cada horno.
  • Lo que admite un horno: su cono, sus programas y su volumen; y lo que necesita el taller: alguien que cargue y descargue entre las 08:00 y las 20:00, y un día entre un bizcocho y su siguiente cocción.
  • El balance de energía: cada cuarto de hora del mes, la electricidad a su tarifa, el gas y el propano, la potencia contratada de 22 kW, la factura de la semana y la serie diaria que alimenta el minimapa.
  • Los comandos: próximo inicio limpio, inicio más barato, una hora más de mantenimiento, cocer ahora y volver al estante, cada uno registrado como un solo paso de deshacer; un bizcocho movido más tarde arrastra su cocción de esmalte.
  • Las tarjetas con su curva, las pegatinas del estante, las cabeceras de los hornos con lo que hace cada uno a la hora del taller y las cifras de las barras.

El código de esta demo.

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

tsx.zip

La integración: niveles de zoom con tres filas de cabecera, rowHeaderColumns, la fila fija de energía, separadores, horario laborable, enlaces construidos a partir de los datos, renderEvent, las áreas de cada tarjeta, el veredicto de arrastre, el menú contextual y los callbacks donde las reglas del taller rechazan, ajustan o alargan una cocción.

/**
 * 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>
  )
}

El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.