# Move and lane

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/move-and-lane/>. 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).

Drag a bar to a new time or onto another lane (also called drag-and-drop rescheduling). A ghost shows where it would land and what that would cause; the drop reports the change for your application to apply.

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

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/move-and-lane/

Each name in `intents` enables one gesture. Without `intents` the schedule is read-only, and pressing a bar pans the plot.

The schedule never changes your data ([ADR-0023](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0023-editing-is-controlled-the-schedule-reports-intents.md)): `onIntent` reports a move and a lane change as values, and [`applyIntent`](https://romanhaendler.github.io/umriss-ui/schedule/api/#applyIntent) is the arithmetic to apply them. A drop across lanes and time reports both, and your handler may apply them, refuse them, or apply one.

Held at the edge of the plot, a drag pans it along; Escape cancels the drag.

## Examples

### Move work in time

List `move` in `intents` and apply what `onIntent` reports with [`applyIntent`](https://romanhaendler.github.io/umriss-ui/schedule/api/#applyIntent); the plan changes only in your state.

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

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

const PROJECTS: readonly Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }];

const START: readonly Subtask[] = [
  { id: "w-102", task: "portal", lane: "arjun", from: day(17), to: day(18, 17), name: "Profile page" },
  { id: "w-105", task: "portal", lane: "chloe", from: day(18), to: day(19, 17), name: "Change of address form" },
];

export default function MoveInTime() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 0), day(21, 0)] } });
  const [work, setWork] = useState(START);
  return (
    <Schedule
      ariaLabel="Two people's work this week"
      height={140}
      intents={["move"]}
      onIntent={(intent) => setWork((current) => current.map((item) => applyIntent(item, intent)))}
    >
      <Lane id="arjun" label="Arjun Mehta" />
      <Lane id="chloe" label="Chloe Durand" />
      <Subtasks data={work} tasks={PROJECTS} />
    </Schedule>
  );
}
```

### Move work onto another lane

Add `lane` to `intents`: a drop reports a move and a lane change separately, and the ghost shows the findings first.

```tsx
import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Dependency, Intent, 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 VEHICLES = [
  { id: "truck-118", label: "Truck FP 118 R" },
  { id: "van-402", label: "Van FP 402 R" },
  { id: "truck-520", label: "Truck FP 520 E" },
  { id: "van-455", label: "E-van FP 455 R" },
  { id: "van-290", label: "Van FP 290 E" },
];

const CONSIGNMENTS: readonly Task[] = [
  { id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "c-2043", name: "C-2043 Oakridge Pharmacy", color: "light-dark(#c2410c, #f08a52)" },
  { id: "c-2045", name: "C-2045 Tamsin's Bakery", color: "light-dark(#be185d, #f06aa6)" },
  { id: "c-2046", name: "C-2046 Pellham Hardware", color: "light-dark(#4d7c0f, #8fc43e)" },
];

const LEGS: readonly Subtask[] = [
  { id: "c-2041-1", task: "c-2041", lane: "truck-118", from: at(6), to: at(7), leadIn: min(15), leadOut: min(10) },
  { id: "c-2043-1", task: "c-2043", lane: "truck-520", from: at(6, 30), to: at(8), leadIn: min(30), leadOut: min(15) },
  { id: "c-2043-2", task: "c-2043", lane: "van-402", from: at(10), to: at(11, 30), leadIn: min(15) },
  { id: "c-2046-2", task: "c-2046", lane: "van-402", from: at(13), to: at(14, 30), leadIn: min(20), leadOut: min(10) },
  { id: "c-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) },
  { id: "c-2045-3", task: "c-2045", lane: "van-290", from: at(15, 45), to: at(16, 30) },
];

const TRANSFERS: readonly Dependency[] = [
  { id: "t-2043-1", from: "c-2043-1", to: "c-2043-2", lag: min(45) },
  { id: "t-2043-2", from: "c-2043-2", to: "c-2043-3", lag: min(25) },
];

export default function MoveToAnotherLane() {
  const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } });
  const [legs, setLegs] = useState(LEGS);
  const [last, setLast] = useState<Intent | null>(null);

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="Five vehicles on Tuesday, 17 March, editable"
        height={284}
        intents={["move", "lane"]}
        onIntent={(intent) => {
          setLast(intent);
          setLegs((current) => current.map((leg) => applyIntent(leg, intent)));
        }}
      >
        {VEHICLES.map((vehicle) => (
          <Lane key={vehicle.id} id={vehicle.id} label={vehicle.label} />
        ))}
        <Dependencies data={TRANSFERS} />
        <Subtasks data={legs} tasks={CONSIGNMENTS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-intent>
        {last === null ? "No intent yet" : JSON.stringify(last)}
      </Text>
    </Stack>
  );
}
```

### Refuse a move that double-books someone

Your `onIntent` decides: here it applies a drop only when [`overlaps`](https://romanhaendler.github.io/umriss-ui/schedule/api/#overlaps) finds no new double booking, and says why otherwise.

```tsx
import { useRef, useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, overlaps, useSchedule } from "@umriss-ui/schedule";
import type { Intent, Subtask } from "@umriss-ui/schedule";

/* Data from the planning world, written out here so the example runs on its own. */

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

interface Person {
  id: string;
  name: string;
  role: "Developer" | "Designer" | "Product manager" | "QA engineer";
  team: "Web" | "Apps";
  /** Hours a week they can be planned for. */
  capacity: number;
}

const PEOPLE: readonly Person[] = [
  { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
  { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
  { id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
  { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
  { id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
  { id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
  { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
  { id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
  { id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
  { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
];

interface Project {
  id: string;
  name: string;
  client: string;
  color: string;
}

const PROJECTS: readonly Project[] = [
  { id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
  { id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
  { id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
];

/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
  id: string;
  /** The project. */
  task: string;
  /** The person. */
  lane: string;
  from: number;
  to: number;
  name: string;
  sprint: string;
  /** Hours, as estimated at planning. */
  estimate: number;
  status: "to do" | "in progress" | "in review" | "done";
}

const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
  ({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });

const WORK: readonly WorkItem[] = [
  item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
  item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
  item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
  item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
  item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
  item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
  item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
  item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
  item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
  item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
  item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
  item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
  item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
  item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
  item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
  item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];

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

const WEB = PEOPLE.filter((person) => person.team === "Web" && WORK.some((item) => item.lane === person.id));
const START: readonly Subtask[] = WORK.filter((item) => WEB.some((person) => person.id === item.lane));

export default function RefuseADoubleBooking() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(9), day(21)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  /* A drop onto another lane at another time reports two intents in one
     tick; each is decided on the plan the one before it left. */
  const plan = useRef(work);
  const [said, setSaid] = useState("Drag a work item onto someone who is busy");

  const onIntent = (intent: Intent) => {
    if (intent.kind === "place") return;
    const next = plan.current.map((item) => applyIntent(item, intent));
    const moved = intent.subtask;
    const clash = overlaps(next).find((o) => o.first === moved || o.second === moved);
    if (clash !== undefined) {
      setSaid(`Not applied: ${moved} would share ${clash.lane}'s time with ${clash.first === moved ? clash.second : clash.first}.`);
      return;
    }
    plan.current = next;
    setWork(next);
    setSaid(`Applied: ${intent.kind} ${moved}.`);
  };

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="Sprint 14 of the web team, editable"
        height={240}
        intents={["move", "lane"]}
        onIntent={onIntent}
      >
        {WEB.map((person) => (
          <Lane key={person.id} id={person.id} label={person.name} />
        ))}
        <Subtasks data={work} tasks={PROJECTS} />
      </Schedule>
      <Text size="sm" tone="secondary" data-decision>
        {said}
      </Text>
    </Stack>
  );
}
```

## When to use something else

- The work is not on the plan yet → Placing from outside
- Some lanes or times must not take some work → Where a subtask may go
- Later work should follow a move → Ripple

## Keyboard

| Key | Action |
|---|---|
| `Alt+← Alt+→` | Proposes moving the active subtask one raster step earlier or later, stepping over blocked time. |
| `Escape` | Cancels a drag in flight: the ghost goes and nothing is reported. |

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. |
| [`leadIn`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. |
| [`leadOut`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. |
| [`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`. |

From `Overlap` — the full table stands on [Findings as data](https://romanhaendler.github.io/umriss-ui/schedule/findings/#type-Overlap).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/findings/#Overlap-lane) *required* | `string` | — | The lane both sit on. |
| [`first`](https://romanhaendler.github.io/umriss-ui/schedule/findings/#Overlap-first) *required* | `string` | — | The subtask that starts earlier (lead-in included); on a tie, the one that stands first in the data. |
| [`second`](https://romanhaendler.github.io/umriss-ui/schedule/findings/#Overlap-second) *required* | `string` | — | The other one. |

## Known limits

- No undo: your application owns the data, and with it the history ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)).
- The keys move work in time only; a lane change needs the pointer.

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