Ripple

Works out which later work has to move when one piece moves: every successor whose dependency no longer fits, pushed by exactly what is missing (also called a cascade). The schedule never runs it; your application decides.

import { ripple, shiftTask, applyIntent } from "@umriss-ui/schedule";

ripple(subtasks, dependencies, intent) returns move intents down the whole chain, in the order they were pushed. A successor keeps its length and is never pulled earlier: room that opens up is the planner's.

shiftTask moves every subtask of one task by the same amount, so all its dependencies keep fitting.

Examples

Push the later stops of a tour

ripple returns the moves that push every stop whose drive no longer fits behind a late one; you decide whether to apply them.

import { useState } from "react";
import { Button, Stack, Text } from "@umriss-ui/core";
import { applyIntent, ripple, useSchedule } from "@umriss-ui/schedule";
import type { Dependency, MoveIntent, Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const min = (n: number) => n * 60_000;

const TOURS: readonly Task[] = [{ id: "T-01", name: "T-01 North depot", color: "light-dark(#0d9488, #3cc7b8)" }];

/* Ten minutes at each stop, twenty minutes' drive between them, and five
   minutes of slack that absorbs part of a delay. */
const START: readonly Subtask[] = [
  { id: "stop-1", task: "T-01", lane: "van-214", from: at(7, 40), to: at(7, 50), name: "Holloway" },
  { id: "stop-2", task: "T-01", lane: "van-214", from: at(8, 15), to: at(8, 25), name: "Marlow" },
  { id: "stop-3", task: "T-01", lane: "van-214", from: at(8, 50), to: at(9), name: "Oakridge" },
  { id: "stop-4", task: "T-01", lane: "van-214", from: at(9, 25), to: at(9, 35), name: "Brixley" },
];

const DRIVES: readonly Dependency[] = [
  { id: "drive-1", from: "stop-1", to: "stop-2", lag: min(20) },
  { id: "drive-2", from: "stop-2", to: "stop-3", lag: min(20) },
  { id: "drive-3", from: "stop-3", to: "stop-4", lag: min(20) },
];

const LATE: MoveIntent = { kind: "move", subtask: "stop-1", from: at(8, 10), to: at(8, 20) };

export default function PushTheLaterStops() {
  const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(7, 15), at(10, 30)] } });
  const [stops, setStops] = useState<readonly Subtask[]>(START);
  /* Nothing to push until the first stop runs late. */
  const late = stops.some((s) => s.id === LATE.subtask && s.from === LATE.from);
  const pushed = late ? ripple(stops, DRIVES, LATE) : [];

  return (
    <Stack gap={3}>
      <Schedule ariaLabel="Tour T-01 on van FP 214 K" height={100}>
        <Lane id="van-214" label="Van FP 214 K" />
        <Dependencies data={DRIVES} />
        <Subtasks data={stops} tasks={TOURS} />
      </Schedule>
      <Stack direction="row" gap={2} align="center">
        <Button size="sm" onClick={() => setStops((current) => current.map((s) => applyIntent(s, LATE)))} disabled={late}>
          First stop 30 minutes late
        </Button>
        <Button
          size="sm"
          onClick={() => setStops((current) => pushed.reduce((data, move) => data.map((s) => applyIntent(s, move)), current))}
          disabled={pushed.length === 0}
        >
          Push {pushed.length} {pushed.length === 1 ? "stop" : "stops"}
        </Button>
        <Button size="sm" variant="ghost" onClick={() => setStops(START)}>
          Reset
        </Button>
      </Stack>
      <Text size="xs" mono tone="muted" data-pushed>
        {pushed.length === 0 ? "Nothing to push" : pushed.map((m) => m.subtask).join(", ")}
      </Text>
    </Stack>
  );
}

Move a whole tour

shiftTask returns one move per stop, all by the same amount, so every drive that fitted still fits.

import { useState } from "react";
import { Button, Stack } from "@umriss-ui/core";
import { applyIntent, shiftTask, useSchedule } from "@umriss-ui/schedule";
import type { Dependency, Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const min = (n: number) => n * 60_000;
const HALF_HOUR = min(30);

const TOURS: readonly Task[] = [
  { id: "T-01", name: "T-01", color: "light-dark(#0d9488, #3cc7b8)" },
  { id: "T-02", name: "T-02", color: "light-dark(#7c3aed, #a98bfa)" },
];

const START: readonly Subtask[] = [
  { id: "t01-1", task: "T-01", lane: "van-214", from: at(7, 40), to: at(7, 50) },
  { id: "t01-2", task: "T-01", lane: "van-214", from: at(8, 15), to: at(8, 25) },
  { id: "t01-3", task: "T-01", lane: "van-214", from: at(8, 50), to: at(9) },
  { id: "t02-1", task: "T-02", lane: "van-377", from: at(8, 10), to: at(8, 20) },
  { id: "t02-2", task: "T-02", lane: "van-377", from: at(8, 45), to: at(8, 55) },
];

const DRIVES: readonly Dependency[] = [
  { id: "d01-1", from: "t01-1", to: "t01-2", lag: min(20) },
  { id: "d01-2", from: "t01-2", to: "t01-3", lag: min(20) },
  { id: "d02-1", from: "t02-1", to: "t02-2", lag: min(20) },
];

export default function MoveAWholeTour() {
  const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(7), at(10, 30)] } });
  const [stops, setStops] = useState<readonly Subtask[]>(START);
  const move = (by: number) =>
    setStops((current) => shiftTask(current, "T-01", by).reduce((data, one) => data.map((s) => applyIntent(s, one)), current));

  return (
    <Stack gap={3}>
      <Schedule ariaLabel="Two tours from North depot" height={140}>
        <Lane id="van-214" label="Van FP 214 K" />
        <Lane id="van-377" label="E-van FP 377 K" />
        <Dependencies data={DRIVES} />
        <Subtasks data={stops} tasks={TOURS} />
      </Schedule>
      <Stack direction="row" gap={2}>
        <Button size="sm" onClick={() => move(-HALF_HOUR)}>
          T-01 half an hour earlier
        </Button>
        <Button size="sm" onClick={() => move(HALF_HOUR)}>
          T-01 half an hour later
        </Button>
      </Stack>
    </Stack>
  );
}

Ripple on every drag

Run ripple inside onIntent and apply its moves with the drop: drag the design later and build and test follow.

import { useState } from "react";
import { applyIntent, ripple, useSchedule } from "@umriss-ui/schedule";
import type { Dependency, Intent, Subtask, Task } from "@umriss-ui/schedule";

const day = (d: number, hours = 9) => new Date(2026, 2, d, hours).getTime();

const PROJECTS: readonly Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }];

const START: readonly Subtask[] = [
  { id: "design", task: "shop", lane: "noah", from: day(16), to: day(17, 17), name: "Search results layout" },
  { id: "build", task: "shop", lane: "chloe", from: day(18), to: day(19, 17), name: "Search results page" },
  { id: "test", task: "shop", lane: "eva", from: day(20), to: day(20, 17), name: "Search regression run" },
];

/* Each step waits for the one before it; a hand-over takes a morning. */
const HANDOVERS: readonly Dependency[] = [
  { id: "to-build", from: "design", to: "build", lag: 4 * 60 * 60_000 },
  { id: "to-test", from: "build", to: "test", lag: 4 * 60 * 60_000 },
];

export default function RippleOnEveryDrag() {
  const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [day(16, 0), day(24, 0)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);

  const onIntent = (intent: Intent) =>
    setWork((current) => {
      const moved = current.map((item) => applyIntent(item, intent));
      return ripple(current, HANDOVERS, intent).reduce((data, push) => data.map((item) => applyIntent(item, push)), moved);
    });

  return (
    <Schedule
      ariaLabel="Design, build and test of the shop search, rippling"
      height={184}
      intents={["move", "stretch"]}
      onIntent={onIntent}
    >
      <Lane id="noah" label="Noah Fischer" />
      <Lane id="chloe" label="Chloe Durand" />
      <Lane id="eva" label="Eva Novak" />
      <Dependencies data={HANDOVERS} />
      <Subtasks data={work} tasks={PROJECTS} />
    </Schedule>
  );
}

Keyboard

The keys of First schedule apply here.

Props on this page

From ScheduleProps — the full table stands on First schedule.

NameTypeDefaultDescription
heightnumber400Height in CSS pixels, both bands included. The width is the host's.
intentsreadonly IntentKind[][]The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023).
childrenReactNode—Lane, Dependencies and Subtasks, in the order they are to stand and be drawn.

Events

NameTypeDefaultDescription
onIntent(intent: Intent) => void—Called once per intent when a drag ends. The data changes only if the caller changes it.

Accessibility

NameTypeDefaultDescription
ariaLabelrequiredstring—What the schedule shows, for a screen reader: "Plan of week 38".

From LaneProps — the full table stands on Lanes.

NameTypeDefaultDescription
idrequiredstring—The identity subtasks name in their lane.
labelReactNodeidWhat the lane header says - the person, vehicle or room. Real text, read by a screen reader.

From SubtasksProps — the full table stands on Subtasks.

NameTypeDefaultDescription
datarequiredreadonly Subtask[]—The subtasks, as the caller holds them. The schedule never changes one.
tasksrequiredreadonly Task[]—The tasks the subtasks belong to - their identity and their colour. A subtask whose task is not among them is drawn in a muted colour.

From Subtask — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the subtask.
taskrequiredstring—The id of the task it belongs to.
lanerequiredstring—The id of the lane it sits on.
fromrequirednumber—Start of the main time.
torequirednumber—End of the main time.
namestring—What the subtask is called, where the schedule names it.

From Task — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the task.
colorrequiredstring—Any CSS colour - a literal, a token, light-dark(…). The colour means what the caller's domain needs it to mean.
namestring—What the task is called, where the schedule names it.

From DependenciesProps — the full table stands on Dependencies.

NameTypeDefaultDescription
datarequiredreadonly Dependency[]—The dependencies, as the caller holds them.

From Dependency — the full table stands on Dependencies.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the dependency.
fromrequiredstring—The id of the subtask it leaves.
torequiredstring—The id of the subtask it arrives at.
lagrequirednumber—The least time between the two anchors; zero where the successor may begin as soon as the predecessor ends.

From ScheduleOptions — the full table stands on View.

NameTypeDefaultDescription
initialViewScheduleView—The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed defaultValue. To go to a view later, call setView. The schedule remembers none: where a view is kept is the application's decision.

From ScheduleView — the full table stands on View.

NameTypeDefaultDescription
domainreadonly [number, number]—The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within zoomLimits.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/schedule

Getting started

Plan

Time

Reading

Editing

Measuring

API index