# Where a subtask may go

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/where-it-may-go/>. 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).

Marks the lanes and times a piece of work may not go to as soon as a drag begins, and holds the ghost where it is allowed. Reach for it when rules of your domain - equipment, skills, leave - limit where work can stand.

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

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/where-it-may-go/

`canMoveTo(subtask, lane)` is asked once per lane when a drag takes hold, and again at the drop. Refused lanes are drawn back and hatched; over one, the ghost stays on the last lane allowed, tied to the pointer by a line.

Blocked time refuses a place in time rather than a lane: a drag cannot put work into it, and Alt with an arrow key steps over it. Work the data already put there is never locked in.

A refusal costs only what it refuses - a drop across a closed lane still reports the move in time - and wears no warning colour, because a rule is nobody's mistake.

## Examples

### Keep work on the lanes that fit

`canMoveTo` names the lanes a subtask may go to; the others are hatched as the drag begins, and a drop there keeps only the move in time.

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

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

/* A plate is one word: a lane header that takes two lines must not tear it
   apart ("FP 214" / "K"). */
const plate = (text: string) => text.replaceAll(" ", "\u00a0");

const GOODS: Task[] = [
  { id: "dry", name: "Dry goods", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "chilled", name: "Chilled goods", color: "light-dark(#c2410c, #f08a52)" },
];

const COOLED = ["cool-1", "cool-2"];

const START: Subtask[] = [
  { id: "parcels", task: "dry", lane: "cool-1", from: at(7), to: at(9) },
  { id: "vaccines", task: "chilled", lane: "cool-2", from: at(9, 30), to: at(11, 30) },
  { id: "books", task: "dry", lane: "dry", from: at(12), to: at(14) },
];

/* Chilled goods go on a cooled van; dry goods go anywhere. */
const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "chilled" || COOLED.includes(lane);

export default function LanesThatFit() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(15)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [last, setLast] = useState("Drag the vaccines onto the dry van");

  /* Only `move` and `lane` are enabled, but the type is the whole union, and
     a `place` names no subtask. */
  const onIntent = (intent: Intent) => {
    if (intent.kind === "place") return;
    setWork((current) => current.map((s) => applyIntent(s, intent)));
    setLast(`${intent.subtask}: ${intent.kind}`);
  };

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="Two cooled vans and a dry van"
        height={190}
        intents={["move", "lane"]}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        <Lane id="cool-1" label={`Cooled van ${plate("FP 377 K")}`} />
        <Lane id="cool-2" label={`Cooled van ${plate("FP 455 R")}`} />
        <Lane id="dry" label={`Van ${plate("FP 214 K")}`} />
        <Subtasks data={work} tasks={GOODS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-move>
        {last}
      </Text>
    </Stack>
  );
}
```

### Apply the same rule to work dragged in

`canMoveTo` is asked about work in `placing` too; the ghost holds on the last lane that allowed it, and a drop lands there.

```tsx
import { useState } from "react";
import { Card, Stack, Text } from "@umriss-ui/core";
import { subtaskFromPlace, useSchedule } from "@umriss-ui/schedule";
import type { Intent, PlacingItem, Subtask, Task } from "@umriss-ui/schedule";

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

/* A plate is one word: a lane header that takes two lines must not tear it
   apart ("FP 214" / "K"). */
const plate = (text: string) => text.replaceAll(" ", "\u00a0");

const GOODS: Task[] = [
  { id: "dry", name: "Dry goods", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "chilled", name: "Chilled goods", color: "light-dark(#c2410c, #f08a52)" },
];

const COOLED = ["cool-1", "cool-2"];

const START: Subtask[] = [
  { id: "parcels", task: "dry", lane: "cool-1", from: at(7), to: at(9) },
  { id: "books", task: "dry", lane: "dry", from: at(12), to: at(14) },
];

const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "chilled" || COOLED.includes(lane);

const WAITING: PlacingItem & { label: string } = {
  item: "insulin",
  label: "Oakridge Pharmacy, chilled · 2 h",
  task: "chilled",
  duration: 2 * 60 * 60_000,
};

export default function DraggedIn() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(15)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [placing, setPlacing] = useState<PlacingItem | null>(null);
  const [last, setLast] = useState("Drag the chilled delivery onto the dry van");

  const onIntent = (intent: Intent) => {
    if (intent.kind !== "place") return;
    setWork((current) => [...current, subtaskFromPlace(intent, `${intent.item}-${current.length}`)]);
    setLast(`${intent.item}: place on ${intent.lane}`);
  };

  return (
    <Stack gap={3}>
      <Card>
        <div
          draggable
          data-waiting={WAITING.item}
          onDragStart={(event) => {
            event.dataTransfer.setData("text/plain", WAITING.item);
            event.dataTransfer.effectAllowed = "copy";
            setPlacing(WAITING);
          }}
          onDragEnd={() => setPlacing(null)}
          style={{ cursor: "grab", padding: "var(--u-space-2) var(--u-space-3)" }}
        >
          <Text size="sm">{WAITING.label}</Text>
        </div>
      </Card>
      <Schedule
        ariaLabel="A chilled delivery waiting beside two cooled vans and a dry van"
        height={190}
        intents={["place"]}
        placing={placing}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        <Lane id="cool-1" label={`Cooled van ${plate("FP 377 K")}`} />
        <Lane id="cool-2" label={`Cooled van ${plate("FP 455 R")}`} />
        <Lane id="dry" label={`Van ${plate("FP 214 K")}`} />
        <Subtasks data={work} tasks={GOODS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-place>
        {last}
      </Text>
    </Stack>
  );
}
```

### Keep work out of someone's leave

Blocked time refuses a place in time where `canMoveTo` refuses a lane; both hold together, and neither wears a warning colour.

```tsx
import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { BlockedTime, 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"),
];

interface Leave {
  id: string;
  person: string;
  from: number;
  to: number;
  reason: "Holiday" | "Sick" | "Training";
}

/** When people are away: blocked time on their lane. */
const LEAVE: readonly Leave[] = [
  { id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
  { id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
  { id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
  { id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
  { id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
  { id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
];

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

const APPS = PEOPLE.filter((person) => person.team === "Apps" && person.role !== "Product manager");
const START: readonly Subtask[] = WORK.filter((item) => APPS.some((person) => person.id === item.lane));
const AWAY: readonly BlockedTime[] = LEAVE.filter((leave) => APPS.some((person) => person.id === leave.person)).map((leave) => ({
  id: leave.id,
  lane: leave.person,
  from: leave.from,
  to: leave.to,
  label: leave.reason,
}));

/* Work stays with people of the role it was planned for. */
const roleOf = (lane: string) => PEOPLE.find((person) => person.id === lane)?.role;
const START_ROLE = new Map(START.map((item) => [item.id, roleOf(item.lane)]));
const mayGo = (item: Subtask, lane: string) => START_ROLE.get(item.id) === roleOf(lane);

export default function AroundLeave() {
  const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [day(9), day(21)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [last, setLast] = useState("Drag Freya's store screenshots into her holiday");

  const onIntent = (intent: Intent) => {
    if (intent.kind === "place") return;
    setWork((current) => current.map((item) => applyIntent(item, intent)));
    setLast(`${intent.subtask}: ${intent.kind}`);
  };

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="The apps team this week, with leave"
        height={236}
        intents={["move", "lane"]}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        {APPS.map((person) => (
          <Lane key={person.id} id={person.id} label={`${person.name}, ${person.role}`} />
        ))}
        <BlockedTimes data={AWAY} />
        <Subtasks data={work} tasks={PROJECTS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-move>
        {last}
      </Text>
    </Stack>
  );
}
```

## When to use something else

- The rule depends on the result, such as no double booking → Move and lane

## 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)). |
| [`canMoveTo`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-canMoveTo) | `(subtask: `[`Subtask`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask)`, lane: string) => boolean` | every lane is open | Whether a subtask may go to a lane. It narrows `"lane"`, it does not enable it. Asked once per lane when a drag takes hold, and asked again at the drop. The lanes it turns down are marked from the first frame of the drag - a planner sees the refusal before meeting it - and over one of them the ghost stays on the last lane that was allowed, the cursor says no and a line ties the ghost to the pointer. A refused lane costs the lane and nothing else: a drop after it still reports the move in time. It is asked for work dragged in from outside as well, with the key and task the application declared in `placing`. |
| [`placing`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-placing) | [`PlacingItem`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-PlacingItem)` \| null` | — | What the application is dragging in from outside while it drags it - its key, task, the length of its main time, its lead-in and lead-out. The browser hands the dragged data over only on the drop, so the ghost before it can only come from here: set it on your own `dragstart`, clear it on `dragend`. Without `"place"` in `intents` no drop is accepted. |
| [`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. |

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 `BlockedTimesProps` — the full table stands on [Blocked time](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#type-BlockedTimesProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTimesProps-data) *required* | `readonly `[`BlockedTime`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#type-BlockedTime)`[]` | — | The blocked time of the lanes - leave, maintenance, unavailability -, as the caller holds it: one list, each interval naming its lane. |

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

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTime-id) *required* | `string` | — | The caller's identity of the interval - what a finding names. |
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTime-lane) *required* | `string` | — | The id of the lane it blocks. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTime-from) *required* | `number` | — | Where it begins. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTime-to) *required* | `number` | — | Where it ends. |
| [`label`](https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/#BlockedTime-label) | `string` | — | Why the lane is blocked - "Leave", "Maintenance" -, where the schedule names it. |

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

- A rule that changes while a drag runs is noticed at the drop, not before.

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