Aller au contenu

Planning des fours céramiques

Les pièces cône 10 d’une adhérente sont prêtes. Cuisez-les dans le seul four qui le peut, quand quelqu’un peut défourner, sans faire exploser le budget énergie.

  • Atelier de céramique
  • Cone Ten
  • Glisser-déposer externe
  • Slots de rendu React
  • Glisser et redimensionner
  • Règles de déplacement
  • Dépendances
  • Zones actives
  • Lignes de synthèse
  • Minimap
  • Thèmes
  • Démo avec données fictives

Mission

0 étapes faites sur 3

Mercredi, 10 h 15. Le service de table en grès d’Inés est dégourdi et émaillé : il lui faut le cône 10, en réduction.

  1. Enfournez le service cône 10 d’Inés dans Dragon, le four à gaz. — À faire

    Glissez l’étiquette INÉS de l’étagère sur la ligne de Dragon, après la cuisson qui y refroidit. Pip et Bess la refusent : ils plafonnent au cône 6 et au cône 8. Ou cliquez sur ENFOURNER sur l’étiquette.

  2. Faites en sorte que quelqu’un soit là pour défourner. — À faire

    Enfourné cet après-midi, il refroidit en pleine nuit. Glissez-le sur jeudi 08:00, ou faites un clic droit et choisissez Prochain départ propre.

  3. Ramenez la semaine sous son budget énergie de 590 €. — À faire

    Le dégourdi du cours du mercredi, dans Bess, monte en pleine pointe de 17 h à 21 h pendant que Pip monte aussi. Glissez-le sur jeudi 08:00, ou faites un clic droit et choisissez Départ le moins cher.

Environ 30 secondes. Les étapes se cochent au fur et à mesure.

Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

Cone Ten
PipC6 · 85 LBessC8 · 220 LDragonC11 · 700 LEmberC06 · 60 LKaiMugs · 18NoorBols · 12NoorBols · 12MargitCarreaux · 40LiorPichets · 8LiorPichets · 8OluVases · 6Cours mer.Bols · 32AdePlats · 5SolTasses · 20RuthThéières · 7Cours sam.Assiettes · 26Entretien des brûleursBoutiqueChawans · 8PiaVases · 4MeiGobelets · 6BoutiqueGobelets · 107031626171033300017181192035117003511910181870003511700ÉlectricitékWh par heuremercredi 21 octobrejeudi 22 octobrevendredi 23 octobresamedi 24 octobredimanche 25 octobrelundi 26 octobremardi 27 octobremercredi 28 octobre0004081216200004081216200004081216200004081216200004081216200004081216200004081216200004

Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

La situation.

Cone Ten est un atelier de céramique partagé et fictif, avec quatre fours : deux fours électriques pour le dégourdi et l’émail cône 6, un four à gaz à sole mobile pour la réduction au cône 10 et un tambour à raku. Les adhérents déposent leurs pièces crues sur l’étagère ; l’atelier les cuit. Une cuisson n’est pas un bloc de temps mais une courbe : le programme monte le four jusqu’à son cône, tient un palier, puis la brique refroidit d’elle-même jusqu’à ce qu’on puisse ouvrir sous 200 °C.

C’est cette courbe que le planificateur doit voir. Le refroidissement dépend de la masse du four et personne ne peut l’écourter ; quelqu’un doit être là pour enfourner et pour défourner ; un dégourdi doit être sorti un jour avant sa cuisson d’émail ; et l’électricité coûte deux fois et demie plus cher entre 17 h et 21 h, avec une puissance souscrite qui fait sauter le disjoncteur si les deux fours électriques montent en même temps. Ce tableau dessine chaque cuisson comme sa propre courbe de température, refuse ce que les fours ne peuvent pas faire et garde la facture d’énergie sous les yeux.

Ce que vous allez faire

Vous enfournerez un lot cône 10 dans le four à gaz, le déplacerez pour que quelqu’un puisse le défourner, puis ramènerez la semaine dans son budget énergie en sortant un dégourdi des heures de pointe.

Où s’arrête la bibliothèque et où commence votre application.

SuperScheduler fournit

  • Contenu des événements par React render slots (renderEvent), avec le même balisage en repli HTML : chaque cuisson dessine sa courbe de température sur l’intervalle exact de l’événement, si bien que le pic et la fin du refroidissement tombent sous leur propre heure dans l’en-tête.
  • Glisser-déposer externe (SuperScheduler.Scheduler.makeDraggable) : les lots passent de l’étagère à un four ; onEventMoving ajuste l’ombre au programme du four sous le pointeur et refuse, avec args.message, ce que ce four ne peut pas cuire.
  • Déplacements et redimensionnements guidés image par image : onEventMoving et onEventResizing arrondissent l’enfournement au quart d’heure, ne laissent le bord de fin changer que le palier et refusent d’écourter le refroidissement ; la carte de glisser (dragCard.html) affiche enfournement, pic, défournement et coût.
  • Zones ancrées dans le temps (le cône sur son pic, la température à l’heure de l’atelier, des hachures de danger sur une surcharge, un drapeau sur un défournement sans personne) et zones au survol (visibility: 'Hover') en grosses pastilles.
  • Une ligne de synthèse figée peinte par onBeforeCellRender, trois lignes d’en-tête écrites par onBeforeTimeHeaderRender (le jour, les heures d’ouverture en zones d’en-tête, l’heure), l’ombrage des heures non ouvrées, des séparateurs à minuit et à l’heure de l’atelier, et le réticule complet.
  • Liens fin-début, niveaux de zoom des heures au mois, la minicarte, l’historique d’annulation, les déplacements au clavier, la carte de survol (eventHover) et un menu contextuel aux couleurs du produit.

Le code de cet exemple décide

  • La physique d’une cuisson : la montée de chaque programme, le palier et le refroidissement naturel depuis le pic du cône jusqu’à 200 °C, fixé par la masse thermique de chaque four.
  • Ce qu’un four accepte : son cône, ses programmes et son volume ; et ce qu’il faut à l’atelier : quelqu’un pour enfourner et défourner entre 8 h et 20 h, et un jour entre un dégourdi et sa cuisson suivante.
  • Le bilan énergétique : chaque quart d’heure du mois, l’électricité à son tarif, le gaz et le propane, la puissance souscrite de 22 kW, la facture de la semaine et la série quotidienne qui alimente la minicarte.
  • Les commandes : prochain départ propre, départ le moins cher, une heure de palier en plus, cuire maintenant et retour à l’étagère, chacune enregistrée comme un seul pas d’annulation ; un dégourdi décalé plus tard entraîne sa cuisson d’émail.
  • Les cartes et leur courbe, les étiquettes de l’étagère, les en-têtes des fours avec ce que fait chacun à l’heure de l’atelier, et les chiffres des barres.

Le code de cette démo.

Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.

tsx.zip

L’intégration : niveaux de zoom à trois lignes d’en-tête, rowHeaderColumns, la ligne d’énergie figée, les séparateurs, les heures ouvrées, les liens construits à partir des données, renderEvent, les zones de chaque carte, le verdict de glisser, le menu contextuel et les callbacks où les règles de l’atelier refusent, ajustent ou prolongent une cuisson.

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

L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.