Gantt: critical path, baselines and constraints

The planning layer: the chain of tasks with no slack, the dates the plan was signed off with, and the dates a task is pinned to. All three are Enterprise options on GanttProConfig, the superset of the free grid's config - structurally assignable to the gantt prop, so the free grid never needs to know about them:

import type { GanttProConfig } from '@svgrid/enterprise'

const cfg: GanttProConfig<any, Task> = { startField: 'start', /* ... */ }
<script lang="ts">
  import { SvGrid, type GridColumns, type GanttDependency } from '@svgrid/grid'
  import { enableGanttView, type GanttProConfig } from '@svgrid/enterprise'

  enableGanttView()

  type Task = {
    id: string
    name: string
    owner?: string
    start: string
    end?: string
    progress?: number
    parentId?: string | null
    milestone?: boolean
    bStart?: string
    bEnd?: string
    con?: string
    conDate?: string
  }

  const pad = (n: number) => String(n).padStart(2, '0')
  const isoDay = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  const base = new Date()
  base.setHours(0, 0, 0, 0)
  base.setDate(base.getDate() - ((base.getDay() + 6) % 7))
  const at = (dayOffset: number) => {
    const d = new Date(base)
    d.setDate(d.getDate() + dayOffset)
    return isoDay(d)
  }

  // A tight chain (interviews, synthesis, implementation) beside a short
  // branch with room (documentation), and a launch that must land by a date.
  let data = $state<Task[]>([
    { id: 'p1', name: 'Discovery', parentId: null, start: at(0), owner: 'Priya' },
    { id: 't1', name: 'Interviews', parentId: 'p1', start: at(0), end: at(4), progress: 100, owner: 'Priya', bStart: at(0), bEnd: at(3) },
    { id: 't2', name: 'Synthesis', parentId: 'p1', start: at(7), end: at(11), progress: 40, owner: 'Marco', bStart: at(4), bEnd: at(9) },
    { id: 'p2', name: 'Build', parentId: null, start: at(14), owner: 'Sven' },
    { id: 't3', name: 'Implementation', parentId: 'p2', start: at(14), end: at(25), progress: 10, owner: 'Sven', bStart: at(10), bEnd: at(23) },
    { id: 't4', name: 'Documentation', parentId: 'p2', start: at(14), end: at(18), progress: 0, owner: 'Lena', bStart: at(14), bEnd: at(18) },
    { id: 't5', name: 'Release', parentId: 'p2', start: at(28), end: at(30), progress: 0, owner: 'Lena', bStart: at(24), bEnd: at(26), con: 'FNLT', conDate: at(32) },
  ])

  const dependencies: GanttDependency[] = [
    { id: 'd1', from: 't1', to: 't2' },
    { id: 'd2', from: 't2', to: 't3' },
    { id: 'd3', from: 't3', to: 't5' },
    { id: 'd4', from: 't4', to: 't5' },
  ]

  const columns: GridColumns<Task> = [
    { field: 'name', header: 'Task', width: 200 },
    { field: 'owner', header: 'Owner', width: 100 },
  ]

  function writeSpan(row: Task, start: Date, end: Date) {
    row.start = isoDay(start)
    const last = new Date(end.getTime() - 86_400_000)
    row.end = isoDay(last < start ? start : last)
  }

  const pro = (c: GanttProConfig<any, Task>) => c
</script>

The critical path

criticalPath: true runs the two passes every planner knows: a forward one giving each task its earliest start and finish, a backward one giving it its latest. Where the two agree the task has no slack - it cannot slip a day without moving the project's finish - and that chain, with the arrows along it, is ringed in red.

<SvGrid {data} {columns} getRowId={(r) => r.id}
  gantt={pro({
    startField: 'start', endField: 'end', parentField: 'parentId',
    dependencies,
    criticalPath: true,
    tableColumns: ['name', '__duration', '__slack'],
    onCriticalPathChange: (keys) => console.log('critical', keys),
  })} />

The __slack column says how many whole working days of room each task has; the critical ones read 0. A phase reports the least slack of the tasks under it.

The passes run in working time, the same time the durations are in: a task's length is its working days, its latest start is counted back over working days, and a weekend between a task and its successor is not room. A five-day task ending Friday whose successor starts Monday is critical - it cannot slip a working day - where a calendar count would give it two days of slack. Set respectWorkingTime: false to schedule in calendar time throughout.

Rules worth knowing, because each is a judgement call rather than arithmetic:

The pass is a pure function, so a report, a KPI or a test can call it without rendering anything:

import { criticalPath, slackDays, makeCalendar } from '@svgrid/enterprise'

const result = criticalPath(times, dependencies, makeCalendar()) // times: Map<id, { start: Date; end: Date }>
result.critical              // Set<string> of task ids
result.finish                // the earliest the project can end
slackDays(result, 'task-7')  // whole working days of room (calendar days without the calendar)

The portfolio console's risk register is exactly that call: the open tasks with under three days of slack, from the same pass the chart draws.

Open the live example: Portfolio office - project console (Gantt)

Baselines

baselineStartField / baselineEndField draw the originally agreed dates as a ghost bar under each task. Where the plan now finishes later than the baseline did, the ghost turns red and the tooltip says by how many days.

<SvGrid {data} {columns} getRowId={(r) => r.id}
  gantt={pro({
    startField: 'start', endField: 'end', parentField: 'parentId',
    baselineStartField: 'bStart', baselineEndField: 'bEnd',
    tooltip: true,
  })} />

Baselines are per task. A phase draws none of its own - its drift is the drift of its tasks - and the programme demo counts the sites running late from the same two fields.

Constraints

constraintField pins a task's dates in the classic planning vocabulary, with the date itself in constraintDateField:

Constraint Means
ASAP (default) Schedule as early as the links allow.
ALAP As late as possible.
MSO / MFO Must start / finish on the date.
SNET / SNLT Start no earlier / no later than.
FNET / FNLT Finish no earlier / no later than.
<SvGrid {data} {columns} getRowId={(r) => r.id}
  gantt={pro({
    startField: 'start', endField: 'end', parentField: 'parentId',
    dependencies,
    constraintField: 'con', constraintDateField: 'conDate',
    editable: true,
    tooltip: true,
    onTaskMove: (e) => writeSpan(e.row, e.start, e.end),
    onTaskResize: (e) => writeSpan(e.row, e.start, e.end),
    onDependenciesChange: (moves) => {
      for (const m of moves) {
        const row = data.find((r) => r.id === m.id)
        if (row) writeSpan(row, m.start, m.end)
      }
    },
  })} />

A constrained task draws a pin at the date the constraint names - a flag at the top of its row and a dashed tick down through it - which stays put while the bar is dragged toward it. The tooltip spells the rule out. A task whose own dates already break its rule gets a dashed red outline and a red pin.

More usefully, the cascade respects the ceiling: pushing a chain into a task that must finish by a contract date stops at the date and leaves the link drawn as unsatisfied, rather than overrunning it silently. Drag Implementation to the right above and watch Release stop at its pin. A constraint and a dependency that disagree have no schedule satisfying both, so showing the conflict beats breaking one of them behind your back.

All three together, on a fit-out with two pinned tasks:

Open the live example: Critical path & baselines (Gantt)

See also

Live examples

  • Portfolio office - project console - A PMO console with the Gantt as the hero: a rail of projects with health and percent complete, live KPIs, and a dockable workspace (SvDockManager) with the plan over a risk register and a remaining-work chart. The plan runs with the critical path and baselines on and is editable; drag a task and the register, the chart and the KPIs recompute from the same rows.
  • Critical path & baselines - A fit-out schedule with the planning layer on: the chain with no slack ringed in red with its arrows, a Slack column for everything else, baseline ghosts that redden where the plan has drifted, and two pinned tasks where a cascade stops at the constraint instead of running past it.