Skip to content
SuperScheduler

Creative agencyStudio North (Fictional business)

Creative agency resource planning

A designer is double-booked. Hand two tasks to a colleague in one drag, check what they unblock, and take it back.

Time scale
Demo with synthetic data
days / weeks
Pro capabilities
DependenciesMulti-selectionRow columnsUndo and redoReact render slots
Studio North
StrategyInes Varga70%DesignMira Okafor120%Jonas Brandt20%Leo Marchetti30%CopySaskia Moreau60%Theo Lindqvist40%ProductionAda Kowalczyk0%ClientClient reviewsBrand auditShopper insightsCurator workshopLogo refinementKey visualsPackaging designTaglinesExhibition textsClient reviewClient reviewClient reviewLaunch kitPrint productionFilm scriptsSocial copyLaunch filmTeaser animationPoster seriesPortfolio refreshOct 19Oct 26Nov 2Nov 919 Mon20 Tue21 Wed22 Thu23 Fri26 Mon27 Tue28 Wed29 Thu30 Fri2 Mon3 Tue4 Wed5 Thu6 Fri9 Mon10 Tue11 Wed12 Thu13 Fri

Loads SuperScheduler Pro only when you ask for it.

The situation

Studio North is a fictional agency where strategists, designers, copywriters and a producer work on three client campaigns at once. Every campaign is a chain of tasks: no key visuals before the logo is refined, no launch kit before the client signs off the review.

In a spreadsheet the trouble shows up late: a designer quietly booked on two campaigns, a review that cannot move, a task handed to someone who cannot do it. The board has to show each person’s load, keep dependencies visible while work changes hands, and make a rebalancing easy to undo.

What you will doYou will select two tasks, hand them to another designer in one drag, watch the load column rebalance while the links follow, and undo the whole move in one step.

Where the library ends and your application begins

SuperScheduler provides

  • Team tree with row-header columns (specialist and load) and their titles.
  • Click and Ctrl/⌘+click selection, the multiselect API, and multi-move across rows (multiMoveVerticalMode: All).
  • Curved finish-to-start links that follow tasks from one row to another, updated through the links prop.
  • Undo/redo history where a multi-move is a single entry, with controlled events through onEventsChange.
  • React task bars (renderEvent) and hover cards (eventHover) from super-scheduler/react-render; day and week zoom levels with weekends hidden.

This example’s code decides

  • Which role can take which discipline, and the refusal message when it cannot.
  • Sprint load per person (booked working days out of ten), written into the load column.
  • Task dependencies, when one is broken, which links are drawn (the selected campaigns, or all) and what the hover card lists.
  • Fixed client reviews, the selection summary, the mission and every label in this page.

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: zoom levels, row-header columns, multiselect and multi-move options, React slots, and the callbacks where the rules accept or refuse a move.

scheduler.tsxtsx
/**
 * Studio North resource plan: teams and specialists over a four-week window, with a load column.
 *
 * Library: timeline, team tree with row-header columns, event multiselection, dependency links,
 * undo history, keyboard, zoom, and React content (task bars and hover cards) through
 * `super-scheduler/react-render`.
 * Application: who can take which task, moving a selection together, the sprint load in the
 * second column, and when a dependency is broken. Those rules live in ./rules and run inside the
 * callbacks below.
 */
import { 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 { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  CAMPAIGN_TONES,
  civilOf,
  clientOf,
  isSpecialistId,
  isTaskId,
  readTaskFields,
  specialistName,
  toResources,
  type TaskEvent,
} from './adapter'
import {
  WINDOW,
  type CampaignId,
  type Discipline,
  type SpecialistId,
  type Task,
  type TaskId,
} from './model'
import {
  dependentsOf,
  moveProblem,
  planGroupMove,
  type DependencyIssue,
  type GroupMovePlan,
  type MoveProblem,
  type PlannedMove,
} from './rules'
import { TaskBar, TaskHoverCard } from './task-card'
import type { StudioNorthText, ZoomId } from './text'

export interface StudioNorthSchedulerProps {
  readonly events: TaskEvent[]
  readonly tasks: readonly Task[]
  readonly issues: readonly DependencyIssue[]
  /** Sprint load per specialist (1 = 100 %), computed by the rules. */
  readonly loads: ReadonlyMap<SpecialistId, number>
  readonly text: StudioNorthText
  /** 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
  /** Every dependency, or only those of the campaigns in `focus` (the selection). */
  readonly showAllLinks: boolean
  readonly focus: ReadonlySet<CampaignId>
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly dateRange: (start: string, end: string) => string
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelectionChange: (ids: TaskId[]) => void
  readonly onMoved: (id: TaskId) => void
  /** A selection dropped together: the application applies and records it as one change. */
  readonly onGroupMove: (moves: readonly PlannedMove[]) => void
  readonly onResized: (id: TaskId) => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 56,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
  {
    id: 'weeks',
    properties: {
      scale: 'Week',
      cellWidth: 168,
      timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Week' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['days', 'weeks']

/** Outline of a selected task, in the campaign's tone; a task that starts too early gets a red one. */
const SELECTED_CLASS = 'sn-selected outline-2 outline-offset-2 outline-[var(--tone)]'
const LATE_CLASS = 'outline-2 outline-offset-1 outline-danger'

/** Width of the two row-header columns (specialist, sprint load); narrower on phones. */
const COLUMN_WIDTHS = { regular: [168, 76], compact: [116, 56] } as const

/** Why one task cannot go to `resource` (a team row, the wrong role, a fixed review), or null. */
function singleProblem(
  data: object,
  resource: SuperScheduler.ResourceId,
): { problem: MoveProblem; discipline: Discipline } | null {
  const fields = readTaskFields(data)
  if (fields === null) return null
  if (!isSpecialistId(resource)) return { problem: 'wrong-role', discipline: fields.discipline }
  const problem = moveProblem(fields, resource)
  return problem === null ? null : { problem, discipline: fields.discipline }
}

export function StudioNorthScheduler(props: StudioNorthSchedulerProps) {
  const {
    events,
    tasks,
    issues,
    loads,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    showAllLinks,
    focus,
    history,
    controlRef,
    dateRange,
  } = props
  const {
    onEventsChange,
    onSelectionChange,
    onMoved,
    onGroupMove,
    onResized,
    onRefused,
    onZoomLevel,
  } = props

  const resources = useMemo(() => toResources(text), [text])
  // Header labels follow the active level. An animated zoom renders the new level's headers before
  // control.scale reports it, so the hook is rebuilt (and the headers repainted) once a zoom lands.
  const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
  const late = useMemo(() => new Set<string>(issues.map((issue) => issue.task)), [issues])

  // Columns are part of the configuration: titles from the page text, widths chosen once on mount.
  const [nameWidth, loadWidth] = useMemo(
    () =>
      window.matchMedia('(max-width: 639px)').matches
        ? COLUMN_WIDTHS.compact
        : COLUMN_WIDTHS.regular,
    [],
  )
  const rowHeaderColumns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () => [
      { title: text.columns.specialist, width: nameWidth },
      { title: text.columns.load, width: loadWidth },
    ],
    [text, nameWidth, loadWidth],
  )

  // Finish-to-start links come from the business model, not from the scheduler. By default only
  // the selected campaigns show theirs, so a busy board stays readable.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    return tasks.flatMap((task) =>
      !showAllLinks && !focus.has(task.campaign)
        ? []
        : task.after.map((before) => ({
            id: `${before}->${task.id}`,
            from: before,
            to: task.id,
            type: 'FinishToStart' as const,
            ...(issues.some((issue) => issue.task === task.id && issue.waitsFor === before)
              ? { cssClass: 'ss-link--late' }
              : {}),
          })),
    )
  }, [tasks, issues, showAllLinks, focus])

  const config = useMemo<SchedulerProps>(() => {
    const refusal = (found: { problem: MoveProblem; discipline: Discipline }) =>
      found.problem === 'fixed' ? text.rules.fixed : text.rules.wrongRole[found.discipline]

    // The selected tasks move together when the dragged one is part of a multiple selection.
    const groupPlan = (
      dragged: SuperScheduler.EventId,
      start: SuperScheduler.Date,
      resource: SuperScheduler.ResourceId,
    ): GroupMovePlan | null => {
      const ids = new Set(controlRef.current?.multiselect.events().map((event) => event.id()))
      if (ids.size < 2 || !ids.has(dragged) || !isTaskId(dragged)) return null
      const selection = tasks.filter((task) => ids.has(task.id))
      return planGroupMove(selection, dragged, civilOf(start), String(resource))
    }

    return {
      locale: schedulerLocale,
      startDate: WINDOW.start,
      days: 28,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Weekends stay hidden: the plan counts working days only.
      showNonBusiness: false,
      useEventBoxes: 'Never',
      // Moves keep a task's working days: dropping a Thursday–Friday task on Friday ends it Monday.
      eventMoveSkipNonBusiness: true,
      // Keep full labels and task text at the week level (24 px per day) on this small board.
      lod: { zoomedOut: 20 },
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderColumns,
      rowHeaderWidth: nameWidth + loadWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: 30,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      // People can be double-booked; the load column makes it visible instead of refusing it.
      allowEventOverlap: true,
      // Click selects, Ctrl/Cmd+click adds to the selection.
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      timeRangeSelectedHandling: 'Disabled',
      dragCard: { duration: false },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        const isGroup = args.row.data.children !== undefined
        args.row.cssClass = isGroup ? 'ss-rh--group' : ''
        const id = args.row.id
        const value = isSpecialistId(id) ? loads.get(id) : undefined
        const column = args.row.columns[1]
        if (value === undefined || column === undefined) return
        const percent = Math.round(value * 100)
        const tone = value > 1 ? 'font-semibold text-danger' : 'text-ink-2'
        column.html = `<span class="tabular ${tone}" title="${SuperScheduler.Util.escapeHtml(text.loadTitle.replace('{value}', String(percent)))}">${percent}%</span>`
        column.horizontalAlignment = 'right'
      },

      onBeforeEventRender: (args) => {
        const fields = readTaskFields(args.data)
        const id = args.data.id
        if (fields === null || !isTaskId(id)) return
        // HTML fallback shown until the React bar (renderEvent) mounts.
        const title = SuperScheduler.Util.escapeHtml(text.tasks[id])
        const client = SuperScheduler.Util.escapeHtml(clientOf(fields.campaign))
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${client}</span></span>`
        const classes = ['ss-ev', toneClass(CAMPAIGN_TONES[fields.campaign])]
        if (fields.fixed === true) classes.push('ss-ev--hatched')
        // Selected tasks get an outline in their tone; the control re-runs this hook on selection changes.
        const selected = args.control.multiselect.events().some((event) => event.id() === id)
        if (late.has(id))
          classes.push(
            'ss-ev--late',
            selected ? 'sn-selected outline-2 outline-offset-2 outline-danger' : LATE_CLASS,
          )
        else if (selected) classes.push(SELECTED_CLASS)
        args.data.cssClass = classes.join(' ')
      },

      // Role compatibility while dragging, for the dragged task and the selection that follows it.
      onEventMoving: (args) => {
        const plan = groupPlan(args.e.id(), args.start, args.resource)
        const found =
          plan === null ? singleProblem(args.e.data, args.resource) : plan.ok ? null : plan
        if (found !== null) {
          args.allowed = false
          args.message = refusal(found)
        }
      },

      // Final check before the commit (also covers keyboard moves). A selection is moved by the
      // application: the library commit is cancelled and the whole plan becomes one change.
      onEventMove: (args) => {
        const plan = groupPlan(args.e.id(), args.newStart, args.newResource)
        if (plan !== null) {
          args.preventDefault()
          if (plan.ok) onGroupMove(plan.moves)
          else onRefused(refusal(plan))
          return
        }
        const found = singleProblem(args.e.data, args.newResource)
        if (found !== null) {
          args.preventDefault()
          onRefused(refusal(found))
        }
      },

      onEventMoved: (args) => {
        const id = args.e.id()
        if (isTaskId(id)) onMoved(id)
      },

      onEventResized: (args) => {
        const id = args.e.id()
        if (isTaskId(id)) onResized(id)
      },

      // The control owns the selection; React keeps a copy of the ids for the detail panel.
      onEventSelected: () => {
        const selected = controlRef.current?.multiselect.events() ?? []
        onSelectionChange(selected.map((event) => event.id()).filter(isTaskId))
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        setHeaderHook(() => createHeaderHook(tag))
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    history,
    controlRef,
    late,
    loads,
    tasks,
    rowHeaderColumns,
    nameWidth,
    loadWidth,
    onEventsChange,
    onMoved,
    onGroupMove,
    onRefused,
    onResized,
    onSelectionChange,
    onZoomLevel,
    schedulerLocale,
    headerHook,
    tag,
    text,
    initialZoom,
  ])

  // React task bars: the late and fixed states come from the rules, the rest from the event.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const id = e.id()
        const fields = readTaskFields(data)
        if (!isTaskId(id) || fields === null) return e.text()
        return (
          <TaskBar
            title={text.tasks[id]}
            client={clientOf(fields.campaign)}
            late={late.has(id)}
            fixed={fields.fixed === true}
            compact={width < 200}
          />
        )
      },
    [late, text],
  )

  // Hover card: what the task waits for and what it unblocks, from the model's dependencies.
  const eventHover = useMemo<NonNullable<SchedulerProps['eventHover']>>(
    () => ({
      delay: 300,
      placement: 'auto',
      render: ({ e }) => {
        const task = tasks.find((candidate) => candidate.id === e.id())
        if (task === undefined) return null
        const name = (id: TaskId) =>
          `${clientOf(tasks.find((candidate) => candidate.id === id)?.campaign ?? 'studio')} · ${text.tasks[id]}`
        return (
          <TaskHoverCard
            eyebrow={`${clientOf(task.campaign)} · ${text.campaigns[task.campaign]}`}
            title={text.tasks[task.id]}
            details={[
              { label: text.fields.owner, value: specialistName(task.owner, text) },
              { label: text.fields.discipline, value: text.disciplines[task.discipline] },
              { label: text.fields.dates, value: dateRange(task.start, task.end) },
            ]}
            waitsFor={task.after.map((id) => ({
              id,
              name: name(id),
              late: issues.some((issue) => issue.task === task.id && issue.waitsFor === id),
            }))}
            unblocks={dependentsOf(task.id, tasks).map((next) => ({
              id: next.id,
              name: name(next.id),
              late: issues.some((issue) => issue.task === next.id && issue.waitsFor === task.id),
            }))}
            labels={{
              waitsFor: text.fields.waitsFor,
              unblocks: text.fields.unblocks,
              nothing: text.hover.nothing,
              hint: text.hover.hint,
            }}
          />
        )
      },
    }),
    [tasks, issues, text, dateRange],
  )

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(WINDOW.start, false, 'left')
  }, [controlRef])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={resources}
      events={events}
      links={links}
      renderEvent={renderEvent}
      eventHover={eventHover}
    />
  )
}
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 problemVideo production planningA shoot runs long. Move the edit that depended on it, see why, and take it back.Read the business caseCreative production