# Ripple

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/ripple/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms-full.txt).

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.

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

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/ripple/

`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`](https://romanhaendler.github.io/umriss-ui/schedule/api/#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`](https://romanhaendler.github.io/umriss-ui/schedule/api/#ripple) returns the moves that push every stop whose drive no longer fits behind a late one; you decide whether to apply them.

```tsx
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`](https://romanhaendler.github.io/umriss-ui/schedule/api/#shiftTask) returns one move per stop, all by the same amount, so every drive that fitted still fits.

```tsx
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`](https://romanhaendler.github.io/umriss-ui/schedule/api/#ripple) inside `onIntent` and apply its moves with the drop: drag the design later and build and test follow.

```tsx
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](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#keyboard-schedule) apply here.

## Props on this page

From `ScheduleProps` — the full table stands on [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-ScheduleProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. |
| [`intents`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-intents) | `readonly `[`IntentKind`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-IntentKind)`[]` | `[]` | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule ([ADR-0023](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0023-editing-is-controlled-the-schedule-reports-intents.md)). |
| [`children`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| [`onIntent`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-onIntent) | `(intent: `[`Intent`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-Intent)`) => void` | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-ariaLabel) *required* | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |

From `LaneProps` — the full table stands on [Lanes](https://romanhaendler.github.io/umriss-ui/schedule/lane/#type-LaneProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. |
| [`label`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-label) | `ReactNode` | `id` | What the lane header says - the person, vehicle or room. Real text, read by a screen reader. |

From `SubtasksProps` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-SubtasksProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-data) *required* | `readonly `[`Subtask`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
| [`tasks`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-tasks) *required* | `readonly `[`Task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-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](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-id) *required* | `string` | — | The caller's identity of the subtask. |
| [`task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-task) *required* | `string` | — | The id of the task it belongs to. |
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-lane) *required* | `string` | — | The id of the lane it sits on. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-from) *required* | `number` | — | Start of the main time. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-to) *required* | `number` | — | End of the main time. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. |

From `Task` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Task).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-id) *required* | `string` | — | The caller's identity of the task. |
| [`color`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-color) *required* | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-name) | `string` | — | What the task is called, where the schedule names it. |

From `DependenciesProps` — the full table stands on [Dependencies](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-DependenciesProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#DependenciesProps-data) *required* | `readonly `[`Dependency`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-Dependency)`[]` | — | The dependencies, as the caller holds them. |

From `Dependency` — the full table stands on [Dependencies](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-Dependency).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-id) *required* | `string` | — | The caller's identity of the dependency. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-from) *required* | `string` | — | The id of the subtask it leaves. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. |
| [`lag`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-lag) *required* | `number` | — | 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](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`initialView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleOptions-initialView) | [`ScheduleView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView) | — | 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](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleView-domain) | `readonly [number, number]` | — | The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within `zoomLimits`. |

## Known limits

- No dependency types, slack or critical path: the schedule is no project-planning engine ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)).
- A dependency joins subtasks of one task, so work of other tasks is never pushed.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
