Skip to content

Observatory night planning

A supernova alert arrives at 23:40. Give it a spectrum before it sets, without losing the transit it displaces.

  • Astronomy
  • Nightglass
  • Hidden time
  • React render slots
  • Move rules
  • Blocked time
  • Summary rows
  • Minimap
  • Continuous zoom
  • Crosshair
  • Theming
  • Demo with synthetic data

Mission

0 of 3 steps done

Wednesday, 23:40. SN 2026qf, found an hour ago, needs a classification spectrum tonight. It sinks below 30° at 01:24.

  1. Give the SN 2026qf alert 40 minutes on the Albor 2.5 m. — To do

    Drag it from the alert queue straight down onto Albor 2.5 m, starting between 23:50 and 00:40. The drag card draws its altitude at the proposed time; later slots sink below 30°.

  2. Move the pre-empted WASP-18 b transit to the robotic Vigía 0.6 m. — To do

    A transit keeps its time: drag it straight down to Vigía 0.6 m (Cumbre is in engineering), or open its menu and choose Move to Vigía 0.6 m.

  3. Switch the console to red light for the rest of the night. — To do

    Press R while the board has focus, or use the Red light toggle in the top bar.

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

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

Nightglass
Alert queueMain domeAlbor2.5 mEast domeCumbre1.2 mRoll-off roofVigía0.6 mFaro0.3 mArcsThArNGC 7793NG-26B-021 · R 40 000NGC 1365NG-26B-014 · R 40 000WASP-18NG-26B-033 · RNGC 1097NG-26B-014 · R 40 000NGC 2440NG-26B-021 · R 40 000ArcsThArNGC 7793NG-26B-021 · R 40 000NGC 1316NG-26B-014 · R 40 000NGC 1097NG-26B-014 · R 40 000Sky flatsg r iNGC 7293NG-26B-052 · Hα [O III]EngineeringSecondary mirror alignment30 DoradusNG-26B-058 · g r iNGC 2440NG-26B-052 · Hα [O III]Sky flatsg r iNGC 7293NG-26B-052 · Hα [O III]NGC 55NG-26B-040 · V ISky flatsB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VSky flatsB VNGC 300NG-26B-040 · VR DoradusNG-26B-063 · B VSky flatsg rField S17NG-26B-071 · g rField S15NG-26B-071 · g rField S16NG-26B-071 · g rSky flatsg rField S17NG-26B-071 · g rField S14NG-26B-071 · g rSN 2026qfTOO-26B-003 · R 40 00081%89%Moonphase · setsWednesday, October 21Thursday, October 222021222300010203040506072021222300

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

The situation.

Nightglass is the night plan of Cerro Alto, a fictional observatory at 2,400 m with four telescopes under three enclosures. The plan runs from sunset to sunrise and daytime does not exist on the board. Twilight is data: sky flats need it, science waits for it to end.

What makes a slot good is the sky. A target must stay above 30° (airmass 2), the Moon brightens the sky around faint targets, and a humid front can close every dome. When a fresh supernova is reported, the night assistant has minutes to find it a telescope before it sets, and whatever it displaces needs a new home.

What you will do

You will place a target-of-opportunity spectrum while the supernova is still above 30°, move the transit it pre-empts to another telescope at the same time, and switch the console to red light.

Where the library ends and your application begins.

SuperScheduler provides

  • Hidden time with onIncludeTimeCell: only nights exist on the axis, each from half an hour before sunset to after sunrise, side by side, with a separator at every night boundary and one at the night clock.
  • Ten-minute cells and zoomLevels from 10-minute detail to the whole run, the continuous zoom gesture and the zoom-ui slider (createZoomSlider); moves, resizes and keyboard steps follow the cells.
  • Three time-header rows written by onBeforeTimeHeaderRender: the night (a groupBy: 'None' row with one header area per night), the hours, and the sky row with its twilight bands and the sunset, darkness and sunrise marks.
  • onBeforeCellRender shades every cell by the altitude of the Sun and disables the hours a forecast closes an enclosure (hatched, and drops there are refused).
  • A frozen Moon row (frozen: 'top') whose events carry each night's phase, setting time and clear-sky chance, above a tree of enclosures and telescopes with four rowHeaderColumns.
  • React render slots (renderEvent from super-scheduler/react-render) draw the altitude curve of every block; the hover card (eventHover) and the drag card show the target over the whole night and at the proposed time.
  • Refusals with reasons in onEventMoving, args.allowOverlap for alerts that pre-empt queue time, moveHDisabled for a transit and moveVDisabled for calibrations, crosshairType: 'Full', undo history, keyboard moves and a context menu in the product's skin.
  • The minimap (super-scheduler/minimap) over the whole run, with a fixed range and a series and tones computed by the application.

This example’s code decides

  • The sky: low-precision Sun and Moon positions, sidereal time, altitudes, airmass and twilight for the site, as pure functions.
  • What a telescope may do: the instrument a mode needs, targets above 30°, science after nautical twilight, sky flats in twilight, closures from the weather forecast and nothing in the past.
  • What an alert pre-empts, which blocks are in conflict, the earliest slot of a block and the telescope that can take a transit at the same time.
  • Dark time allocated per night and the weather loss behind the minimap tones, the night clock with UT and sidereal time, and red-light mode.

The code behind this demo.

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

tsx.zip

The integration: the night-only timeline (onIncludeTimeCell), zoom levels on ten-minute cells, the three header rows, the frozen Moon row, rowHeaderColumns, separators, the React block cards, the hover card, the drag card with the altitude at the proposed time, the context menu, and the callbacks where the sky refuses a drop or lets an alert pre-empt.

/**
 * Nightglass night plan: four telescopes under three enclosures over a run of seven nights, in
 * ten-minute cells, with daytime taken off the axis.
 *
 * Library: hidden time (onIncludeTimeCell keeps the nights only), zoom levels on one cell scale
 * from 10-minute detail to the whole run, three time-header rows written by
 * onBeforeTimeHeaderRender (a 'None' row with one area per night, the hours, the sky), cells
 * shaded and disabled by onBeforeCellRender, a frozen Moon row with its own events, a resource
 * tree with four rowHeaderColumns, React cards (renderEvent) over HTML fallbacks, separators at
 * night boundaries and at the night clock, the full crosshair, drag and resize rules with the drag
 * card, per-frame overlap for alerts, moveHDisabled / moveVDisabled locks, the hover card
 * (eventHover), keyboard and undo history, and a context menu. Application: the sky (./astro),
 * what the night allows and what an alert pre-empts (./rules), and what the board paints from it
 * (./sky-paint, ./block-card).
 */
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format } from '../../i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { defaultHints } 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,
  MOON_ROW,
  blockTitle,
  enclosureMembers,
  isEnclosureRow,
  isRowId,
  lookOf,
  readBlockFields,
  toResources,
  toneOf,
  type BlockEvent,
} from './adapter'
import {
  BlockCard,
  MoonCard,
  chartModel,
  chartSvg,
  useObservationHover,
  altitudeSummary,
  cardHtml,
  cardParts,
  cardSize,
  moonHtml,
  moonParts,
  type HoverFact,
  type ObservationCardModel,
} from './block-card'
import { crossing, targetAltitude } from './astro'
import {
  NIGHTS,
  NOW,
  PLAN,
  SITE,
  QUEUE_ID,
  SLOT_MINUTES,
  TONIGHT,
  isTelescopeId,
  programmeById,
  targetById,
  telescopeById,
  type Block,
} from './model'
import {
  altitudeProfile,
  blockStatus,
  earliestSlot,
  isLocked,
  modeOf,
  moonWarning,
  civilOf,
  nightAt,
  nightSky,
  overlapping,
  preemptible,
  refusalOf,
  rehome,
  warningsOf,
  type Issue,
  type Refusal,
} from './rules'
import {
  cellPainter,
  clock,
  cornerHtml,
  degrees,
  domeHtml,
  formatDec,
  formatRa,
  hourHeader,
  hoursMinutes,
  isNightCell,
  nightAreas,
  nightSpans,
  pixelOf,
  seeingHtml,
  skyAreas,
  telescopeHtml,
  telescopeName,
} from './sky-paint'
import type { NightglassText, ZoomId } from './text'

export type Layout = 'wide' | 'medium' | 'narrow'

/** What the board asks the demo to do (context menu, keyboard, sheet). */
export type BlockAction = 'details' | 'earliest' | 'rehome' | 'red-light'

export interface NightglassSchedulerProps {
  readonly events: BlockEvent[]
  /** The same events as business objects, for rules that look at other blocks. */
  readonly blocks: readonly Block[]
  readonly issues: readonly Issue[]
  readonly text: NightglassText
  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 block shown in the side sheet: it gets the selection ring. */
  readonly selected: string | null
  /** Red-light mode: a class on the root, which the hover card copies; the menu gets its own theme. */
  readonly red: boolean
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string, edge: 'start' | 'end') => void
  readonly onRefused: (id: string, message: string) => void
  readonly onAction: (action: BlockAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
  /**
   * Wide stages show four row-header columns; medium ones fold the instrument into the
   * telescope column and keep the dome; phones keep the telescope column only.
   */
  readonly layout: Layout
}

/** One cell scale, three widths: zooming changes only the pixels per ten minutes. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'minutes', properties: { cellWidth: BOARD.zoom.minutes } },
  { id: 'night', properties: { cellWidth: BOARD.zoom.night } },
  { id: 'run', properties: { cellWidth: BOARD.zoom.run } },
]
const ZOOM_IDS: readonly ZoomId[] = ['minutes', 'night', 'run']

/** The night, its hours and its sky. */
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'None', height: BOARD.header.night },
  { groupBy: 'Hour', height: BOARD.header.hours, format: 'HH' },
  { groupBy: 'None', height: BOARD.header.sky },
]

/** Night boundaries and the night clock: fixed instants of the scenario. */
function separators(text: NightglassText, tag: string): SuperScheduler.SeparatorData[] {
  return [
    ...NIGHTS.slice(1).map((night) => ({
      location: new Date(nightSky(night).window.start).toISOString().slice(0, 19),
      layer: 'BelowEvents' as const,
      width: 1,
      cssClass: 'ng-sep-night',
    })),
    {
      location: NOW,
      layer: 'AboveEvents' as const,
      width: 1,
      cssClass: 'ng-now',
      toolTip: `${text.chrome.clock} ${clock(parseCivil(NOW), tag)}`,
    },
  ]
}

/** A refusal as a complete sentence, with the sky's numbers. */
export function refusalMessage(refusal: Refusal, text: NightglassText, tag: string): string {
  const time = (ticks: number) => clock(ticks, tag)
  const target = (id: string) => targetById(id)?.name ?? id
  switch (refusal.kind) {
    case 'done':
    case 'running':
    case 'engineering':
    case 'queue':
      return text.rules[refusal.kind]
    case 'past':
      return format(text.rules.past, { time: time(parseCivil(NOW)) })
    case 'instrument':
      return format(text.rules.instrument[refusal.mode], {
        telescope: telescopeName(refusal.telescope, text, tag),
      })
    case 'sets':
    case 'rises':
    case 'low':
      return format(text.rules[refusal.kind], {
        target: target(refusal.target),
        time: time(refusal.at),
        edge: time(refusal.edge),
        altitude: degrees(refusal.altitude),
      })
    case 'bright':
      return format(refusal.dawn ? text.rules.brightDawn : text.rules.brightDusk, {
        time: time(refusal.limit),
        edge: time(refusal.edge),
        sun: degrees(refusal.sun),
      })
    case 'twilight':
      return format(text.rules.twilight, { edge: time(refusal.edge), sun: degrees(refusal.sun) })
    case 'closed':
      return format(
        refusal.closure.cause === 'humidity' ? text.rules.closedHumidity : text.rules.closedWind,
        {
          enclosure: text.enclosures[refusal.closure.enclosure],
          start: time(refusal.closure.start),
          end: time(refusal.closure.end),
          value: String(refusal.closure.value),
        },
      )
    case 'pre-empt':
      return format(text.rules.preempt, { name: refusal.blocker })
  }
}

/** Menu label of the earliest slot: where it would go, or why it stays. */
export function earliestLabel(
  block: Block,
  blocks: readonly Block[],
  text: NightglassText,
  tag: string,
): { label: string; enabled: boolean } {
  const slot = earliestSlot(block.id, blocks)
  if (slot === null) return { label: text.actions.noSlot, enabled: false }
  if (slot.row === block.row && slot.start === parseCivil(block.start))
    return { label: text.actions.atEarliest, enabled: false }
  return {
    label: format(text.actions.earliest, {
      time: clock(slot.start, tag),
      telescope: slot.row === QUEUE_ID ? text.queue.row : telescopeName(slot.row, text, tag),
    }),
    enabled: true,
  }
}

/** Menu label of the move to another telescope at the same time. */
export function rehomeLabel(
  block: Block,
  blocks: readonly Block[],
  text: NightglassText,
  tag: string,
): { label: string; enabled: boolean } {
  const slot = rehome(block.id, blocks)
  return slot === null || slot.row === QUEUE_ID
    ? { label: text.actions.noRehome, enabled: false }
    : {
        label: format(text.actions.rehome, { telescope: telescopeName(slot.row, text, tag) }),
        enabled: true,
      }
}

/** The hover card and side-sheet facts of a block, from the model and the rules. */
export function observationModel(
  block: Block,
  issues: readonly Issue[],
  text: NightglassText,
  common: DemoCommon,
  tag: string,
): ObservationCardModel {
  const target = targetById(block.target)
  const programme = block.programme
  const start = parseCivil(block.start)
  const end = parseCivil(block.end)
  const night = nightAt(start) ?? nightAt(end - 1)
  const status = block.row === QUEUE_ID ? 'waiting' : blockStatus(block)
  const own = issues.find((issue) => issue.block === block.id)
  const mode = modeOf(block)
  const where = block.row === QUEUE_ID ? text.queue.row : telescopeName(block.row, text, tag)
  const facts: HoverFact[] = []
  let rule: ObservationCardModel['rule'] = null
  if (target !== null) {
    const profile = altitudeProfile(target, start, end)
    const summary = altitudeSummary(profile)
    const moon = moonWarning(target.id, start, end)
    facts.push(
      {
        label: text.fields.window,
        value: format(text.values.window, {
          start: clock(start, tag),
          end: clock(end, tag),
          span: hoursMinutes((end - start) / 60_000),
        }),
        mono: true,
      },
      {
        label: text.fields.coordinates,
        value: format(text.values.coordinates, {
          ra: formatRa(target.ra),
          dec: formatDec(target.dec),
        }),
        mono: true,
      },
      {
        label: text.fields.programme,
        value:
          programme === undefined
            ? '—'
            : format(text.values.programme, {
                id: programme,
                pi: programmeById(programme)?.pi ?? '',
              }),
        mono: true,
      },
      { label: text.fields.setup, value: block.setup ?? '—', mono: true },
      {
        label: text.fields.altitude,
        value: format(text.values.altitude, summary),
        mono: true,
        alert: profile.min < 30,
      },
    )
    if (moon !== null)
      facts.push({
        label: text.fields.moon,
        value: format(text.values.moon, {
          value: new Intl.NumberFormat(tag, { style: 'percent' }).format(moon.fraction),
          separation: String(moon.separation),
        }),
        alert: true,
      })
    rule =
      own?.kind === 'pre-empted'
        ? { state: 'blocked', text: text.status['pre-empted'] }
        : own?.kind === 'weather'
          ? { state: 'blocked', text: text.status.weather }
          : moon !== null
            ? {
                state: 'warn',
                text: format(text.warn.moon, {
                  value: new Intl.NumberFormat(tag, { style: 'percent' }).format(moon.fraction),
                  separation: String(moon.separation),
                }),
              }
            : {
                state: 'ok',
                text: format(text.warn.clear, { min: summary.min, airmass: summary.airmass }),
              }
  } else {
    facts.push(
      { label: text.fields.telescope, value: where },
      { label: text.fields.starts, value: clock(start, tag), mono: true },
      { label: text.fields.ends, value: clock(end, tag), mono: true },
    )
    if (block.setup !== undefined)
      facts.push({ label: text.fields.setup, value: block.setup, mono: true })
  }
  const statusLabel =
    own !== undefined
      ? { label: text.status[own.kind], tone: 'danger' as const }
      : status === 'running'
        ? { label: text.status.running, tone: 'success' as const }
        : status === 'waiting'
          ? { label: text.status.waiting, tone: 'warning' as const }
          : { label: block.kind === 'calibration' ? text.status.calibration : text.status[status] }
  return {
    id: block.id,
    tone: toneOf(block),
    eyebrow: `${mode === null ? text.kinds[block.kind] : text.modes[mode]} · ${where}`,
    title: blockTitle(block, text),
    status: statusLabel,
    chart:
      night === null || target === null
        ? null
        : { night, target: target.id, start, end, label: text.actions.chart },
    facts,
    rule,
    hints: defaultHints(common, { move: !isLocked(block) }),
  }
}

/** Beside a waiting alert: its programme and when the target sinks below 30° tonight. */
function alertSide(block: Block, text: NightglassText, tag: string): string {
  const escape = SuperScheduler.Util.escapeHtml
  const target = targetById(block.target)
  const night = nightAt(parseCivil(block.start))
  if (target === null || night === null) return ''
  const { window } = nightSky(night)
  const sets = crossing(
    (ticks) => targetAltitude(target, ticks, SITE),
    30,
    'down',
    parseCivil(block.start),
    window.end,
    5 * 60_000,
  )
  const programme = programmeById(block.programme)
  return (
    `<span class="ng-side"><b>${escape(target.name)}</b>` +
    `<span>${escape([programme?.id, block.setup].filter(Boolean).join(' · '))}</span>` +
    (sets === null
      ? ''
      : `<span class="ng-side__sets">${escape(format(text.card.setsAt, { time: clock(sets, tag) }))}</span>`) +
    `</span>`
  )
}

export function NightglassScheduler(props: NightglassSchedulerProps) {
  const {
    events,
    blocks,
    issues,
    text,
    common,
    tag,
    schedulerLocale,
    initialZoom,
    selected,
    red,
    history,
    controlRef,
    layout,
  } = props
  const narrow = layout === 'narrow'
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onAction, onZoomLevel } = props

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

  // Header labels depend on the cell width; a settled zoom level gets a fresh hook so the control
  // labels its headers again (the hook reads args.control.cellWidth while painting).
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => {
    const hook = (args: SuperScheduler.SchedulerBeforeTimeHeaderRenderArgs) => {
      const header = args.header
      const width = args.control.cellWidth ?? BOARD.zoom.night
      const start = header.start.getTime()
      const end = header.end.getTime()
      if (header.level === 0) {
        header.html = ''
        header.cssClass = 'ng-th-nights'
        header.areas = nightAreas(width, text, tag)
        header.toolTip = ''
        return
      }
      if (header.level === 1) {
        const painted = hourHeader(start, end, width, text, tag)
        header.html = painted.html
        header.toolTip = painted.tip
        header.cssClass = 'ng-th-hour'
        return
      }
      header.html = ''
      header.areas = skyAreas(width, text, tag)
      header.cssClass = 'ng-th-sky'
      header.toolTip = ''
    }
    return { level, hook }
  }, [level, tag, text]).hook

  // A refused drop (sky, instrument, closure, the night log) is said on release.
  const refusal = useDropRefusal(onRefused)

  const paintCell = useMemo(() => cellPainter(text, tag), [text, tag])

  // Hover card: the target over its whole night, the facts and the rule that applies.
  const card = useCallback(
    (event: SuperScheduler.Event) => {
      const block = blocks.find((candidate) => candidate.id === String(event.id()))
      return block === undefined ? null : observationModel(block, issues, text, common, tag)
    },
    [blocks, issues, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useObservationHover(card, tag)

  // Drag verdict: the night's refusal or warnings, then the target's altitude at the proposed
  // time, with the part under 30° in red.
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const proposed = (info: SuperScheduler.DragCardInfo) => {
      const block = blocks.find((candidate) => candidate.id === String(info.eventId))
      if (block === undefined || info.resource === null || !isRowId(info.resource)) return null
      return {
        block,
        placement: { row: info.resource, start: info.start.getTime(), end: info.end.getTime() },
      }
    }
    const base = createDragCard({
      tag,
      common,
      rowName: (id) =>
        id === QUEUE_ID ? text.queue.row : isTelescopeId(id) ? telescopeName(id, text, tag) : null,
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const warnings = warningsOf(next.block, next.placement, blocks)
        const lines = warnings.map((warning) =>
          warning.kind === 'moon'
            ? format(text.warn.moon, {
                value: new Intl.NumberFormat(tag, { style: 'percent' }).format(warning.fraction),
                separation: String(warning.separation),
              })
            : format(text.warn.preempts, {
                names: warning.blocks
                  .map((id) => {
                    const other = blocks.find((candidate) => candidate.id === id)
                    return other === undefined ? id : blockTitle(other, text)
                  })
                  .join(', '),
              }),
        )
        return lines.length === 0 ? null : lines.join(' ')
      },
    })
    return {
      ...base,
      // The header marker names the minute: a night plan is read to the minute, not the day.
      markerFormat: 'HH:mm',
      html: (info) => {
        const html = base.html?.(info) ?? null
        const next = proposed(info)
        const target = targetById(next?.block.target)
        if (html === null || next === null || target === null) return html
        const profile = altitudeProfile(target, next.placement.start, next.placement.end)
        const night = nightAt(next.placement.start) ?? nightAt(next.placement.end - 1)
        if (night === null) return html
        const low = profile.min < 30
        const caption = format(text.warn.clear, altitudeSummary(profile))
        const chart =
          `<div class="ng-vd-alt" data-low="${low}">` +
          `<span class="ng-vd-alt__label">${text.warn.chart}</span>` +
          chartSvg(
            chartModel(night, target.id, next.placement.start, next.placement.end, tag, {
              width: 248,
              height: 64,
            }),
            low,
          ) +
          (low ? '' : `<span class="ng-vd-alt__caption">${caption}</span>`) +
          `</div>`
        return `${html.slice(0, -'</div>'.length)}${chart}</div>`
      },
    }
  }, [blocks, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real consequence. Red
  // light gets its own theme (the menu lives on <body>, outside the board).
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: red ? 'ng-red-menu' : skinMenuTheme('observatory-nights'),
        title: (event) => format(common.kit.menuTitle, { name: event.text() }),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const block = blocks.find((candidate) => candidate.id === id)
          if (block === undefined) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (!isLocked(block) && block.timeCritical !== true && block.kind !== 'calibration') {
            const earliest = earliestLabel(block, blocks, text, tag)
            entries.push({
              label: earliest.label,
              icon: 'slot',
              primary: block.kind === 'too' && block.row === QUEUE_ID,
              disabled: !earliest.enabled,
              run: () => onAction('earliest', id),
            })
          }
          if (block.kind === 'science' && !isLocked(block)) {
            const move = rehomeLabel(block, blocks, text, tag)
            entries.push({
              label: move.label,
              icon: 'handover',
              primary: issues.some((issue) => issue.block === id && issue.kind === 'pre-empted'),
              disabled: !move.enabled,
              run: () => onAction('rehome', id),
            })
          }
          entries.push('separator', {
            label: red ? text.actions.redLightOff : text.actions.redLightOn,
            icon: 'focus',
            keys: ['R'],
            run: () => onAction('red-light', id),
          })
          return entries
        },
      }),
    [blocks, issues, red, common, text, tag, onAction, suppressHover],
  )

  // React cards: the same parts as the HTML fallback, sized by the rendered width.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width, lod }) => {
        const fields = readBlockFields(data)
        if (fields === null) return e.text()
        if (fields.kind === 'moon')
          return fields.night === undefined ? null : (
            <MoonCard parts={moonParts(fields.night, text, tag, width)} />
          )
        const block = blocks.find((candidate) => candidate.id === String(e.id()))
        if (block === undefined) return e.text()
        const moon = moonWarning(block.target, parseCivil(block.start), parseCivil(block.end))
        return (
          <BlockCard
            parts={cardParts(
              block,
              issues,
              moon?.separation ?? null,
              text,
              lookOf(fields),
              blockTitle(block, text),
              cardSize(width, lod),
            )}
          />
        )
      },
    [blocks, issues, text, tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const targetName = (id: string | undefined) => targetById(id)?.name ?? ''
    const columns =
      layout === 'narrow'
        ? [{ text: text.columns.telescope, width: BOARD.narrowColumn }]
        : layout === 'medium'
          ? [
              { text: text.columns.telescope, width: BOARD.columns.telescope },
              { text: text.columns.dome, width: BOARD.columns.dome },
            ]
          : [
              { text: text.columns.telescope, width: BOARD.columns.telescope },
              { text: text.columns.dome, width: BOARD.columns.dome },
              { text: text.columns.seeing, width: BOARD.columns.seeing },
            ]
    const headerWidth = columns.reduce((sum, column) => sum + column.width, 0)
    const sep = separators(text, tag)
    return {
      locale: schedulerLocale,
      startDate: PLAN.start,
      days: PLAN.days,
      scale: 'CellDuration',
      cellDuration: SLOT_MINUTES,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Pinch and Ctrl+wheel between the whole run and 10-minute detail; the slider follows.
      zoomGesture: { min: 2, max: 48, wheel: 'ctrl' },
      timeHeaders: TIME_HEADERS,
      // Daytime does not exist for this product: only cells inside a night's window are kept.
      onIncludeTimeCell: (args) => {
        args.cell.visible = isNightCell(args.cell.start.getTime(), args.cell.end.getTime())
      },
      useEventBoxes: 'Never',
      height: '100%',
      cssClass: `${skinClass('observatory-nights')} ng-board${red ? ' ng-red' : ''}`,
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: BOARD.treeIndent,
      rowHeaderColumns: columns,
      rowHeaderWidth: headerWidth,
      rowHeaderWidthAutoFit: false,
      // The corner above the column titles names the run and keys the night and hour rows.
      onBeforeCornerRender: (args) => {
        args.html = cornerHtml(text, narrow)
      },
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMargin,
      rowMarginBottom: BOARD.rowMargin,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      crosshairType: 'Full',
      separators: sep,
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      keyboardOptions: { zoomKeys: true },
      history,
      controlRef,
      onEventsChange,

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const cells = row.columns
        const first = cells[0]
        if (first === undefined) return
        const name = String(row.name)
        for (const cell of cells.slice(1)) cell.html = ''
        if (id === MOON_ROW) {
          row.cssClass = 'ng-row ng-row--moon'
          first.html = `<span class="ng-rh"><span class="ng-rh__name">${escape(name)}</span><span class="ng-rh__meta">${escape(text.moon.meta)}</span></span>`
          return
        }
        if (id === QUEUE_ID) {
          row.cssClass = 'ng-row ng-row--queue'
          const waiting = blocks.filter((block) => block.row === QUEUE_ID).length
          first.html =
            `<span class="ng-rh"><span class="ng-rh__name">${escape(name)}</span>` +
            `<span class="ng-rh__meta"><span class="ng-count" data-zero="${waiting === 0}">${waiting}</span>${escape(text.queue.meta)}</span></span>`
          return
        }
        if (isEnclosureRow(id)) {
          row.cssClass = 'ss-rh--group ng-row ng-row--group'
          row.html = `<span class="ng-group">${escape(name)}<span class="ng-group__count">${enclosureMembers(id).length}</span></span>`
          first.html = row.html
          return
        }
        const telescope = telescopeById(String(id))
        if (telescope === null) return
        row.cssClass = `ng-row ng-row--telescope ng-row--${telescope.id}`
        first.html = telescopeHtml(telescope, text, tag, layout !== 'narrow')
        const [, dome, seeing] = cells
        if (dome !== undefined) dome.html = domeHtml(telescope, blocks, text, targetName)
        if (seeing !== undefined) seeing.html = seeingHtml(telescope, text, tag)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const resource = String(cell.resource)
        if (resource === MOON_ROW) {
          cell.properties.cssClass = 'ng-cell ng-cell--moon'
          return
        }
        const row = telescopeById(resource)?.id ?? (resource === QUEUE_ID ? 'queue' : 'other')
        const painted = paintCell(cell.start.getTime(), cell.end.getTime(), row)
        cell.properties.cssClass = painted.css
        if (painted.disabled) {
          cell.properties.disabled = true
          cell.properties.bubbleHtml = painted.title
        }
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const fields = readBlockFields(data)
        if (fields === null) return
        if (fields.kind === 'moon') {
          const night = fields.night
          if (night === undefined) return
          const sky = nightSky(night)
          // A night narrower than 300 px keeps the phase and the moonset tick. The settled zoom level
          // (not the control's width mid-gesture) decides, and changing it repaints these events.
          const cellWidth = BOARD.zoom[level]
          const wide = cellWidth * 72 >= 300
          const parts = moonParts(night, text, tag, wide ? 400 : 120)
          data.html = moonHtml(parts)
          data.cssClass = 'ng-ev ng-ev--moon'
          // Moonset, marked where it happens on the axis (a time-anchored area of the event).
          const set = sky.moon.set
          // On a narrow night the time is written only where it fits before the next night starts.
          const span = nightSpans(cellWidth).find((candidate) => candidate.night === night)
          const fits =
            set !== null &&
            (wide || (span !== undefined && pixelOf(set, cellWidth) - span.left + 58 <= span.width))
          data.areas =
            set === null
              ? []
              : [
                  {
                    start: civilOf(set),
                    width: 58,
                    top: 0,
                    bottom: 0,
                    cssClass: 'ng-moonset',
                    html: fits
                      ? `<span aria-hidden="true">${SuperScheduler.Util.escapeHtml(clock(set, tag))}</span>`
                      : '',
                    toolTip: format(text.header.moonset, { time: clock(set, tag) }),
                  },
                ]
          data.ariaLabel = format(text.moon.aria, {
            night: new Date(parseCivil(night)).toISOString().slice(5, 10),
            phase: sky.moon.waxing ? text.moon.waxing : text.moon.waning,
            value: parts.lit,
            clear: parts.clear,
          })
          return
        }
        const block = blocks.find((candidate) => candidate.id === String(data.id))
        if (block === undefined) return
        const look = lookOf(fields)
        const moon = moonWarning(block.target, parseCivil(block.start), parseCivil(block.end))
        const own = issues.filter((issue) => issue.block === block.id)
        // The fallback is the full card; React replaces it with the same markup sized to the block.
        data.html = cardHtml(
          cardParts(
            block,
            issues,
            moon?.separation ?? null,
            text,
            look,
            blockTitle(block, text),
            'full',
          ),
        )
        const status = block.row === QUEUE_ID ? 'planned' : blockStatus(block)
        const classes = ['ng-ev', `ng-ev--${look}`, toneClass(toneOf(fields))]
        if (status !== 'planned') classes.push(`ng-${status}`)
        for (const issue of own) classes.push(`ng-${issue.kind}`)
        if (moon !== null) classes.push('ng-moonwarn')
        if (block.timeCritical === true) classes.push('ng-critical')
        if (block.id === selected) classes.push('ng-selected')
        data.cssClass = classes.join(' ')
        // An alert waiting in the queue says what it is beside itself: the block is only 40 min.
        if (block.row === QUEUE_ID) data.htmlRight = alertSide(block, text, tag)
        const where = block.row === QUEUE_ID ? text.queue.row : telescopeName(block.row, text, tag)
        data.ariaLabel = [
          blockTitle(block, text),
          text.kinds[block.kind],
          where,
          `${clock(parseCivil(block.start), tag)}–${clock(parseCivil(block.end), tag)}`,
          ...own.map((issue) => text.status[issue.kind]),
        ].join(', ')
      },

      // The sky, the instruments, the weather and the night log decide; alerts may pre-empt.
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(args.resource)) return
        const placement = {
          row: args.resource,
          start: args.start.getTime(),
          end: args.end.getTime(),
        }
        const reason = refusalOf(block, placement, blocks)
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        if (block.kind === 'too' && placement.row !== QUEUE_ID) {
          const others = overlapping(block.id, placement, blocks)
          if (others.length > 0 && others.every((other) => preemptible(other)))
            args.allowOverlap = true
        }
        const first = args.conflicts[0]
        refusal.track(
          id,
          args.allowOverlap === true || first === undefined
            ? null
            : format(common.kit.overlaps, { name: first.text() }),
        )
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(block.row)) return
        const reason = refusalOf(
          block,
          { row: block.row, start: args.start.getTime(), end: args.end.getTime() },
          blocks,
        )
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          refusal.track(id, message)
          return
        }
        const first = args.conflicts[0]
        refusal.track(
          id,
          first === undefined ? null : format(common.kit.overlaps, { name: first.text() }),
        )
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const id = String(args.e.id())
        const block = blocks.find((candidate) => candidate.id === id)
        if (block === undefined || !isRowId(args.newResource)) {
          args.preventDefault()
          return
        }
        const reason = refusalOf(
          block,
          { row: args.newResource, start: args.newStart.getTime(), end: args.newEnd.getTime() },
          blocks,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, refusalMessage(reason, text, tag))
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()))
      },
      onEventResized: (args) => {
        refusal.clear()
        onResized(String(args.e.id()), args.what)
      },
      // A click (or Enter) docks the block into the side sheet.
      onEventClick: (args) => {
        if (String(args.e.id()).startsWith('moon-')) return
        closeHover()
        onSelect(String(args.e.id()))
      },
      // R toggles red light while the board has focus.
      onKeyDown: (args) => {
        const key = args.originalEvent
        if (key.key !== 'r' && key.key !== 'R') return
        if (key.metaKey || key.ctrlKey || key.altKey) return
        args.preventDefault()
        onAction('red-light', '')
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    history,
    controlRef,
    issues,
    blocks,
    selected,
    red,
    paintCell,
    common,
    refusal,
    closeHover,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onAction,
    onZoomLevel,
    schedulerLocale,
    tag,
    text,
    initialZoom,
    level,
    layout,
    narrow,
  ])

  // Imperative and once: open on tonight's window (on a phone, on the night clock).
  useEffect(() => {
    controlRef.current?.scrollTo(
      narrow ? NOW : new Date(nightSky(TONIGHT).window.start).toISOString().slice(0, 19),
      false,
      narrow ? 'middle' : 'left',
    )
    // The layout at mount decides the opening view; a later resize keeps wherever the user is.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlRef])

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

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