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.
import { useSchedule } from "@umriss-ui/schedule";
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.
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.
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>
);
}
canMoveTo is asked about work in placing too; the ghost holds on the last lane that allowed it, and a drop lands there.
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>
);
}
Blocked time refuses a place in time where canMoveTo refuses a lane; both hold together, and neither wears a warning colour.
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>
);
}
The keys of First schedule apply here.
From ScheduleProps — the full table stands on First schedule.
| Name | Type | Default | Description |
|---|---|---|---|
height | number | 400 | Height in CSS pixels, both bands included. The width is the host's. |
intents | readonly IntentKind[] | [] | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023). |
canMoveTo | (subtask: 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 | 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 | ReactNode | — | Lane, Dependencies and Subtasks, in the order they are to stand and
be drawn. |
| Name | Type | Default | Description |
|---|---|---|---|
onIntent | (intent: Intent) => void | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabelrequired | string | — | What the schedule shows, for a screen reader: "Plan of week 38". |
From LaneProps — the full table stands on Lanes.
From SubtasksProps — the full table stands on Subtasks.
From Subtask — the full table stands on Subtasks.
From Task — the full table stands on Subtasks.
From BlockedTimesProps — the full table stands on Blocked time.
| Name | Type | Default | Description |
|---|---|---|---|
datarequired | readonly 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.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | The caller's identity of the interval - what a finding names. |
lanerequired | string | — | The id of the lane it blocks. |
fromrequired | number | — | Where it begins. |
torequired | number | — | Where it ends. |
label | string | — | Why the lane is blocked - "Leave", "Maintenance" -, where the schedule names it. |
From ScheduleOptions — the full table stands on View.
| Name | Type | Default | Description |
|---|---|---|---|
initialView | 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. |
What umriss deliberately does not build, and why: ADR-0032.