LogisticsHarborworks (Fictional business)
Port berth planning
A ship arrives twelve hours late. Move its berth window, then bring its tug and cranes along.
- Time scale
- Demo with synthetic data
- hours / days
- Pro capabilities
- Multi-day eventsResource treeDependenciesContinuous zoomMinimap
Loads SuperScheduler Pro only when you ask for it.
The situation
Harborworks is a fictional terminal with three berths, three ship-to-shore cranes, a mobile harbour crane and two tugs. The berth planner works round the clock: every call needs a berth long and deep enough for the ship, a tug to bring it alongside and cranes that can reach that berth.
Delays are the normal case. When a ship reports a new arrival time its window moves, and everything tied to it has to move too: the tug job, the crane gangs, sometimes the next ship. A board that only shows bars hides that cascade; this one draws the links, flags what no longer fits and keeps quay occupancy in view.
What you will doYou will move a delayed ship to its new berthing time, realign its tug and cranes with one command and solve the crane that collides with maintenance.
Where the library ends and your application begins
SuperScheduler provides
- Grouped resource tree of tugs, berths and cranes, with a custom row header per asset.
- Long bars at exact times (useEventBoxes: 'Never') that drag by whole cells with eventMoveByCell.
- Start-to-start and finish-to-start links drawn from your data, styled per link with a CSS class.
- Zoom levels from hours to days, with gestures and an animated switch.
- Minimap with a viewport brush that pans and zooms, plus overlap prevention, undo history and keyboard moves.
This example’s code decides
- Which ship fits which berth (length and draft) and which crane reaches which berth: refusals in onEventMoving.
- What a conflict is: a window before its ETB, a crane outside its window, a tug that does not finish at berthing.
- The realign command: a pure function that moves linked operations and records one undo step.
- The quay occupancy series fed to the minimap, and the busiest-day summary next to it.
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, exact-time bars, links built from the data, and the callbacks where terminal rules refuse a drop.
/**
* Harborworks berth plan: tugs, berths and cranes over two weeks of vessel calls, round the clock.
*
* Library: grouped resource tree, long events at exact times, drag/resize, links, zoom levels,
* keyboard and undo history. Application: which ship fits which berth, which crane reaches which
* berth, and what counts as a conflict. Those rules live in ./rules and run in the callbacks below.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { format, plural } from '../../i18n/format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
BOARD,
assetMeta,
callById,
isAssetId,
readOperationFields,
toResources,
toneOf,
type OperationEvent,
} from './adapter'
import { INITIAL_VIEW_START, PLAN, type Operation, type VesselCall } from './model'
import { berthById, moveRefusal, type Issue, type MoveRefusal } from './rules'
import type { HarborText, ZoomId } from './text'
export interface HarborSchedulerProps {
readonly events: OperationEvent[]
/** The same events as business objects, for rules that look at other operations. */
readonly operations: readonly Operation[]
readonly calls: readonly VesselCall[]
readonly issues: readonly Issue[]
readonly text: HarborText
/** 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 showLinks: 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: (message: string) => void
readonly onZoomLevel: (level: ZoomId) => void
}
/** Four-hour watches by default; hours to plan a berthing, days to see the fortnight. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 40,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
{
id: 'watches',
properties: {
scale: 'CellDuration',
cellDuration: 240,
cellWidth: BOARD.cellWidth,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
{
id: 'days',
properties: {
scale: 'Day',
cellWidth: 150,
timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'watches', 'days']
/** A refusal message with the numbers of the ship and the berth it was dropped on. */
function refusalMessage(
reason: MoveRefusal,
call: VesselCall | null,
target: SuperScheduler.ResourceId,
text: HarborText,
tag: string,
): string {
const berth = berthById(String(target))
const number = (value: number) =>
new Intl.NumberFormat(tag, { maximumFractionDigits: 1 }).format(value)
return format(text.rules[reason], {
length: number(call?.length ?? 0),
draft: number(call?.draft ?? 0),
berthLength: number(berth?.length ?? 0),
berthDraft: number(berth?.draft ?? 0),
})
}
export function HarborScheduler(props: HarborSchedulerProps) {
const {
events,
operations,
calls,
issues,
text,
tag,
schedulerLocale,
initialZoom,
showLinks,
history,
controlRef,
} = props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// Header labels depend on the level's scale. A zoom animation lands on the new scale 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.
const [level, setLevel] = useState<ZoomId>(initialZoom)
const timeHeaders = useMemo(() => ({ level, hook: createHeaderHook(tag) }), [level, tag]).hook
// A drop refused in onEventMoving never reaches onEventMove: 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],
)
// Links come from the business model: tugs finish before berthing, cranes start after it.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
if (!showLinks) return []
const flagged = new Set(issues.map((issue) => issue.op))
const windows = new Map(
events.flatMap((event) =>
event.kind === 'berth' && event.call !== undefined ? [[event.call, event.id]] : [],
),
)
return events.flatMap((event): SuperScheduler.LinkData[] => {
const window = event.call === undefined ? undefined : windows.get(event.call)
if (window === undefined || (event.kind !== 'tug' && event.kind !== 'crane')) return []
const late = flagged.has(String(event.id)) ? { cssClass: 'ss-link--late' } : {}
return event.kind === 'tug'
? [
{
id: `${String(event.id)}->${String(window)}`,
from: event.id,
to: window,
type: 'FinishToStart',
...late,
},
]
: [
{
id: `${String(window)}->${String(event.id)}`,
from: window,
to: event.id,
type: 'StartToStart',
...late,
},
]
})
}, [events, issues, showLinks])
const config = useMemo<SchedulerProps>(() => {
const flagged = new Set(issues.map((issue) => issue.op))
return {
locale: schedulerLocale,
startDate: PLAN.start,
days: PLAN.days,
// ISO weeks in every locale, as the static preview draws them.
weekStarts: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// Exact times (a window from 06:30 stays at 06:30); drags move by whole cells.
useEventBoxes: 'Never',
eventMoveByCell: true,
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: BOARD.rowHeaderWidth,
rowHeaderWidthAutoFit: false,
eventHeight: BOARD.eventHeight,
rowMarginTop: BOARD.rowMarginTop,
rowMarginBottom: BOARD.rowMarginBottom,
allowEventOverlap: false,
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeRowHeaderRender: (args) => {
const id = args.row.id
if (!isAssetId(id)) {
args.row.cssClass = 'ss-rh--group'
return
}
const name = SuperScheduler.Util.escapeHtml(args.row.name)
const meta = SuperScheduler.Util.escapeHtml(assetMeta(id, text))
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 tabular-nums">${meta}</span></span>`
},
onBeforeEventRender: (args) => {
const fields = readOperationFields(args.data)
if (fields === null) return
const call = callById(fields.call)
const title = SuperScheduler.Util.escapeHtml(call?.vessel ?? text.maintenance)
const meta =
fields.kind === 'crane' && fields.moves !== undefined
? plural(text.moves, fields.moves, tag)
: fields.kind === 'berth' && call !== null
? call.id
: ''
const details =
meta === ''
? ''
: `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(meta)}</span>`
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span>${details}</span>`
const classes = ['ss-ev', toneClass(toneOf(fields))]
if (fields.kind === 'maintenance') classes.push('ss-ev--hatched')
if (call?.planned === true) classes.push('ss-ev--ghost')
if (flagged.has(String(args.data.id))) classes.push('hw-conflict')
args.data.cssClass = classes.join(' ')
},
// Berth size, crane reach and asset kinds are terminal rules; overlap is enforced by the library.
onEventMoving: (args) => {
const fields = readOperationFields(args.e.data)
if (fields === null) return
const reason = moveRefusal(fields, String(args.resource), calls, operations)
const message =
reason === null
? null
: refusalMessage(reason, callById(fields.call), args.resource, text, tag)
if (message !== null) {
args.allowed = false
args.message = message
}
refuseOnRelease(message)
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const fields = readOperationFields(args.e.data)
if (fields === null) return
const reason = moveRefusal(fields, String(args.newResource), calls, operations)
if (reason !== null) {
args.preventDefault()
onRefused(refusalMessage(reason, callById(fields.call), args.newResource, text, tag))
}
},
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,
issues,
calls,
operations,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
refuseOnRelease,
schedulerLocale,
tag,
text,
initialZoom,
])
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return (
<SuperSchedulerComponent
{...config}
onBeforeTimeHeaderRender={timeHeaders}
resources={resources}
events={events}
links={links}
/>
)
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.