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
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.
/**
* 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}
/>
)
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.