Skip to content
SuperScheduler

HospitalityCasa Noma (Fictional business)

Hotel room planning

A shower leaks in Room 104. Rehouse the next guest, block the room for the plumber and find the nights that are already full.

Time scale
Demo with synthetic data
days / months
Pro capabilities
Resource treeDrag and resizeMinimapBlocked timeTheming
Casa Noma
Standard double101Street102Street103Courtyard104CourtyardSuperior double201Balcony202Balcony203CourtyardJunior suite301Terrace302TerraceGarden suite401GardenHaddadNovakFerreiraSatoBrandtO’NeillJensenDuboisTanakaLindgrenSilvaKayaOkaforBianchiPetrovVargaLaineMorenoOut of orderKellerNakamuraFischerOrtegaWrightCostaKimHaasIvanovaDuarteQuinnAlbersSousaVidalHartEriksenNavarroOlsenSalehRinaldiParkMercerAndersenGarcíaBeckerOct 26Nov 2Nov 9Nov 1626 Mon27 Tue28 Wed29 Thu30 Fri31 Sat1 Sun2 Mon3 Tue4 Wed5 Thu6 Fri7 Sat8 Sun9 Mon10 Tue11 Wed12 Thu13 Fri14 Sat15 Sun16 Mon17 Tue

Loads SuperScheduler Pro only when you ask for it.

The situation

Casa Noma is a fictional ten-room boutique hotel: four standard doubles, three superiors, two junior suites and a garden suite. The front desk plans on a tape chart, one row per room and one column per night. Guests arrive at 14:00 and leave at 11:00, so on a turnover day one stay ends and the next begins in the same column without overlapping.

Rooms break. When one has to come off sale, the desk must rehouse the guests booked into it without downgrading anyone, fit the repair between a check-out and a check-in, and watch the nights that are already full. Bars alone cannot tell a quiet night from a sold-out one; this chart counts the free rooms per type and night.

What you will doYou will move a reservation out of a broken room (an upgrade is fine, a downgrade is refused), extend the repair to three nights at real hotel times and find the sold-out nights from the occupancy strip.

Where the library ends and your application begins

SuperScheduler provides

  • Resource tree of room types and rooms, with group rows that show free rooms per night through onBeforeCellRender.
  • Stays at exact times on a night scale (useEventBoxes: 'Never') that move by whole nights with eventMoveByCell.
  • Disabled cells for the closed garden suite, overlap prevention, and resizing you can steer in onEventResizing.
  • Minimap fed with your own series, zoom levels, keyboard moves and undo history.
  • Theming with tokens and classes scoped to the control root (cssClass): pill-shaped stays, status colours, today’s column.

This example’s code decides

  • Upgrades yes, downgrades no, room capacity and no check-ins in the past: refusals in onEventMoving and onEventResizing.
  • Hotel times for resized edges: stays end at 11:00, repairs at 14:00, never less than one night.
  • Each stay’s front-desk status (departed, in house, due in, confirmed, option) from the business date.
  • Nightly occupancy, free rooms per type and night, and the next-seven-nights summary.

The code behind this demo

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

The integration: night scale with exact times, availability cells, disabled days, and the callbacks where front-desk rules refuse or snap a change.

scheduler.tsxtsx
/**
 * Casa Noma tape chart: rooms grouped by room type over four weeks, one column per night.
 *
 * Library: resource tree, exact-time stays (check-in 14:00, check-out 11:00) on a day scale,
 * drag/resize, disabled cells, cell and event rendering hooks, zoom levels, keyboard and history.
 * Application: upgrades and downgrades, capacity, hotel times for resized edges, the out-of-order
 * room and the free rooms per type and night. Those rules live in ./rules and run below.
 */
import { useCallback, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerBeforeTimeHeaderRenderArgs,
  SchedulerEventsChangeArgs,
  SchedulerProps,
} from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { plural } from '../../i18n/format'
import { DAY, parseCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  CHART_GEOMETRY,
  STATUS_TONES,
  isItemEvent,
  isRoomTypeId,
  readItemFields,
  toItem,
  toResources,
  type ItemEvent,
} from './adapter'
import { BUSINESS_NOW, CHART, CLOSURES, type RoomItem } from './model'
import {
  freeByType,
  isClosed,
  isUpgrade,
  moveRefusal,
  nightsOf,
  resizeTo,
  roomById,
  statusOf,
  type MoveRefusal,
} from './rules'
import type { CasaNomaText, ZoomId } from './text'

export interface CasaNomaSchedulerProps {
  readonly events: ItemEvent[]
  /** The same events as business objects, for the availability cells. */
  readonly items: readonly RoomItem[]
  readonly text: CasaNomaText
  /** 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
  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: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** One night per column: a fortnight to work in, the whole month to look ahead. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'fortnight',
    properties: {
      scale: 'Day',
      cellWidth: CHART_GEOMETRY.cellWidth,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
  {
    id: 'month',
    properties: {
      scale: 'Day',
      cellWidth: 30,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['fortnight', 'month']
const CHART_START = parseCivil(CHART.start)
const TODAY = parseCivil(BUSINESS_NOW.slice(0, 10))
const NOW = parseCivil(BUSINESS_NOW)

function readItem(data: SuperScheduler.EventData): RoomItem | null {
  return isItemEvent(data) ? toItem(data) : null
}

export function CasaNomaScheduler(props: CasaNomaSchedulerProps) {
  const { events, items, text, tag, schedulerLocale, initialZoom, history, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

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

  // Day labels shorten below a cell width. A zoom animation lands after the headers of its last
  // frames were drawn, so each settled level gets a fresh hook: a new hook makes the control label
  // its headers again. Today's column is marked on top of the shared labels.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => {
    const labels = createHeaderHook(tag)
    return {
      level,
      hook: (args: SchedulerBeforeTimeHeaderRenderArgs) => {
        labels(args)
        if (args.header.level === 1 && args.header.start.getTime() === TODAY)
          args.header.cssClass = 'cn-today'
      },
    }
  }, [level, tag]).hook
  const free = useMemo(() => freeByType(items, CLOSURES, CHART.start, CHART.days), [items])

  // A change refused while dragging never reaches onEventMove/onEventResize: announce the refusal
  // of the last drag frame when the pointer is released (a frame that is allowed again clears it).
  const refusal = useRef<string | null>(null)
  const refuseOnRelease = useCallback(
    (message: string | null) => {
      if (message !== null && refusal.current === null) {
        window.addEventListener(
          'pointerup',
          () => {
            setTimeout(() => {
              if (refusal.current !== null) onRefused(refusal.current)
              refusal.current = null
            }, 0)
          },
          { once: true },
        )
      }
      refusal.current = message
    },
    [onRefused],
  )

  const config = useMemo<SchedulerProps>(() => {
    const refusalText = (reason: MoveRefusal | null) =>
      reason === null ? null : text.rules[reason]

    return {
      locale: schedulerLocale,
      startDate: CHART.start,
      days: CHART.days,
      // Hotel weeks run Monday to Sunday in every locale (as the static preview draws them).
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Exact hotel times on a night scale: a stay is drawn from 14:00 to 11:00 and keeps those
      // times when it is dragged by whole nights. Resized edges are snapped by the hotel rules.
      useEventBoxes: 'Never',
      eventMoveByCell: true,
      snapToGridEventResizing: false,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: CHART_GEOMETRY.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: CHART_GEOMETRY.eventHeight,
      rowMarginTop: CHART_GEOMETRY.rowMarginTop,
      rowMarginBottom: CHART_GEOMETRY.rowMarginBottom,
      allowEventOverlap: false,
      // The Casa Noma skin (theme.css) is scoped to the control root.
      cssClass: 'casa-noma',
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      // Room types are group rows; each room shows its view as a detail.
      onBeforeRowHeaderRender: (args) => {
        const room = roomById(String(args.row.id))
        if (room === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const name = SuperScheduler.Util.escapeHtml(args.row.name)
        const view = SuperScheduler.Util.escapeHtml(text.views[room.view])
        args.row.html =
          `<span class="flex min-w-0 items-baseline justify-between gap-2"><span class="truncate">${name}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3">${view}</span></span>`
      },

      // Group rows show the rooms still free each night; closed days disable a room's cells.
      onBeforeCellRender: (args) => {
        const { cell } = args
        const day = cell.start.getTime()
        const today = day === TODAY ? ' cn-today' : ''
        if (isRoomTypeId(cell.resource)) {
          const count = free[cell.resource][Math.round((day - CHART_START) / DAY)]
          if (count === undefined) return
          cell.properties.html = `<span class="cn-free">${count === null ? '–' : count}</span>`
          cell.properties.cssClass = `cn-avail${count === 0 ? ' cn-full' : ''}${today}`
          return
        }
        if (isClosed(String(cell.resource), day, CLOSURES)) cell.properties.disabled = true
        else if (today !== '') cell.properties.cssClass = 'cn-today'
      },

      onBeforeEventRender: (args) => {
        const fields = readItemFields(args.data)
        if (fields === null) return
        const start = args.data.start.value
        const end = args.data.end.value
        if (fields.kind === 'repair') {
          const title = SuperScheduler.Util.escapeHtml(text.outOfOrder)
          const nights = SuperScheduler.Util.escapeHtml(
            plural(text.nights, nightsOf(start, end), tag),
          )
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${nights}</span></span>`
          args.data.cssClass = `ss-ev cn-item cn-repair ss-ev--hatched ${toneClass('blocked')}`
          return
        }
        const status = statusOf(
          { start, end, ...(fields.option === true ? { option: true } : {}) },
          BUSINESS_NOW,
        )
        const room = args.data.resource
        const upgrade =
          room !== undefined && isUpgrade({ room: String(room), booked: fields.booked })
        const guest = SuperScheduler.Util.escapeHtml(fields.guest)
        const nights = SuperScheduler.Util.escapeHtml(
          plural(text.nights, nightsOf(start, end), tag),
        )
        const badge = upgrade
          ? `<span class="ss-evt__badge">${SuperScheduler.Util.escapeHtml(text.upgrade)}</span>`
          : ''
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${guest}</span><span class="ss-evt__meta">${nights}</span>${badge}</span>`
        const classes = [
          'ss-ev',
          'cn-item',
          'cn-stay',
          `cn-${status}`,
          toneClass(STATUS_TONES[status]),
        ]
        if (status === 'option') classes.push('ss-ev--ghost')
        args.data.cssClass = classes.join(' ')
      },

      // Categories, capacity and the past are front-desk rules; overlap and closed days are the library's.
      onEventMoving: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const message = refusalText(
          moveRefusal(item, String(args.resource), BUSINESS_NOW, args.start.getTime()),
        )
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        refuseOnRelease(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const message = refusalText(
          moveRefusal(item, String(args.newResource), BUSINESS_NOW, args.newStart.getTime()),
        )
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
        }
      },

      // Edges snap to hotel times: stays end at 11:00 and start at 14:00, repairs the other way round.
      onEventResizing: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const edge = args.what
        const next = resizeTo(item, edge, (edge === 'end' ? args.end : args.start).getTime())
        args.start = new SuperScheduler.Date(next.start)
        args.end = new SuperScheduler.Date(next.end)
        const message = edge === 'start' ? startRefusal(item, next.start, text) : null
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        refuseOnRelease(message)
      },

      onEventResize: (args) => {
        const item = readItem(args.e.data)
        if (item === null) return
        const next = resizeTo(
          item,
          args.what,
          (args.what === 'end' ? args.newEnd : args.newStart).getTime(),
        )
        const message = args.what === 'start' ? startRefusal(item, next.start, text) : null
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
          return
        }
        args.newStart = new SuperScheduler.Date(next.start)
        args.newEnd = new SuperScheduler.Date(next.end)
      },

      onEventMoved: (args) => onMoved(String(args.e.id())),
      onEventResized: (args) => onResized(String(args.e.id()), args.what),
      onEventClick: (args) => 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)
      },
    }
  }, [
    history,
    controlRef,
    free,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    refuseOnRelease,
    schedulerLocale,
    tag,
    text,
    initialZoom,
  ])

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

/** A guest in the house keeps the check-in; a guest still to come cannot check in in the past. */
function startRefusal(item: RoomItem, start: number, text: CasaNomaText): string | null {
  if (item.kind !== 'stay') return null
  const status = statusOf(item, BUSINESS_NOW)
  if (status === 'in-house') return text.rules.checkedIn
  return start < NOW ? text.rules.past : null
}
Download the example source (.zip)The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.

Share this scenario

The link opens this example with the current view (time scale and options). It never contains your edits or personal data.

A different business, a related problemFleet rental planningA compact is grounded on pickup day. Hand its rental to another car, keep the cleaning slot and see where the fleet runs out.Read the business caseHospitality and rentals