# @umriss-ui/schedule 0.40.0 > React Gantt-style schedule for data-dense applications - work on lanes over time, with dependencies, blocked time, findings and controlled editing. Install with `npm install @umriss-ui/schedule @umriss-ui/core @umriss-ui/charts`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/schedule/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and what it deliberately does not do. The pages index stands in https://romanhaendler.github.io/umriss-ui/schedule/llms.txt. ## Scenarios Composed, realistic screens built from the package. A numbered mark on the screen is an element with `data-callout`. ### Hand over the on-call week The engineer going off call checks who carries the pager until next Monday and hands on the duties that fall into someone's leave. 1. Who holds the pager right now, read off the rota at the present moment. 2. One lane per engineer, primary and secondary duties in two colours; the line marks now. 3. Leila's leave, hatched on her lane: two of her duties fall into it. 4. The duties in leave, listed from the same data the schedule draws - drag each onto a colleague and it leaves the list. Built from: First schedule, Blocked time, Now line, Move and lane, Findings as data, Alert, Badge. ```tsx import { useState } from "react"; import { Alert, Badge, Stack, Text } from "@umriss-ui/core"; import { applyIntent, findings, useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime, Intent, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the operations world, written out here so the example runs on its own. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */ const NOW = at(17, 10, 30); interface Engineer { id: string; name: string; team: string; } const ENGINEERS: readonly Engineer[] = [ { id: "priya", name: "Priya Raman", team: "Payments" }, { id: "jonas", name: "Jonas Keller", team: "Payments" }, { id: "ada", name: "Ada Mwangi", team: "Identity" }, { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" }, { id: "leila", name: "Leila Haddad", team: "Discovery" }, { id: "sam", name: "Sam Okafor", team: "Messaging" }, { id: "ines", name: "Ines Duarte", team: "Integrations" }, { id: "felix", name: "Felix Brandt", team: "Insights" }, ]; interface OnCall { id: string; engineer: string; rotation: "primary" | "secondary"; from: number; to: number; } /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the secondary of one day is the primary of the next. */ const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id }, { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id }, ].map(({ rotation, engineer }) => ({ id: `${rotation}-${16 + day}`, engineer, rotation, from: at(16 + day, 9), to: at(17 + day, 9), }))).flat(); const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime(); const ROTATIONS: readonly Task[] = [ { id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" }, { id: "secondary", name: "Secondary", color: "light-dark(#0d9488, #3cc7b8)" }, ]; /* A rota entry is already a subtask: the rotation is its task, the engineer its lane. */ const START: readonly Subtask[] = ONCALL.map((duty) => ({ id: duty.id, task: duty.rotation, lane: duty.engineer, from: duty.from, to: duty.to, name: duty.rotation === "primary" ? "Primary" : "Secondary", })); const LEAVE: readonly BlockedTime[] = [ { id: "leila-leave", lane: "leila", from: day(20), to: day(23), label: "Leave", }, ]; const nameOf = (id: string) => ENGINEERS.find((one) => one.id === id)?.name ?? id; const when = (instant: number) => new Date(instant).toLocaleString("en-GB", { weekday: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); export default function HandOver() { const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [day(16, 6), day(23, 12)] } }); const [rota, setRota] = useState(START); const onIntent = (intent: Intent) => { if (intent.kind === "place") return; setRota((current) => current.map((duty) => applyIntent(duty, intent))); }; const onCall = rota.filter((duty) => duty.from <= NOW && NOW < duty.to); const inLeave = findings(rota, [], LEAVE).inBlockedTime; return ( On call now {onCall.map((duty) => ( {`${duty.name}: ${nameOf(duty.lane)}`} ))}
duty.name ?? ""} > {ENGINEERS.map((engineer) => ( ))}
Hatched: Leila Haddad is on leave from Friday to Sunday.
{inLeave.length === 0 ? ( Every duty this week has someone who is here to take it. ) : (
    {inLeave.map((found) => { const duty = rota.find((one) => one.id === found.subtask); return (
  • {`${duty?.name ?? found.subtask} of ${nameOf(found.lane)}, ${when(found.from)} to ${when(found.to)}`}
  • ); })}
Drag a duty onto a colleague's lane to hand it on.
)}
); } ``` ### Plan tomorrow's tours A dispatcher lays out tomorrow's delivery tours van by van and moves them until every stop arrives inside the window its customer was promised. 1. One lane per vehicle, grouped by the depot it leaves from. 2. Each tour is one task: loading, then its stops, joined by the drive between them. 3. The tours with a stop that arrives after its window closed, the latest first, with the customers it concerns. 4. Starting a tour earlier moves every stop of it at once - one intent per stop. Built from: First schedule, Lane groups, Dependencies, Ripple, Badge, Button. ```tsx import { useState } from "react"; import { Badge, Button, Stack, Text } from "@umriss-ui/core"; import { applyIntent, ripple, shiftTask, useSchedule } from "@umriss-ui/schedule"; import type { Dependency, Intent, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the logistics world, written out here so the example runs on its own. */ /** A small LCG - the same numbers on every computer. */ function random(seed: number): () => number { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; } const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const MINUTE = 60_000; const DEPOTS = [ { id: "north", name: "North depot" }, { id: "river", name: "Riverside depot" }, { id: "east", name: "East Gate depot" }, ] as const; interface Vehicle { id: string; plate: string; type: "van" | "e-van" | "truck"; /** Payload, in kg. */ capacity: number; depot: string; } const VEHICLES: readonly Vehicle[] = [ { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" }, { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" }, { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" }, { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" }, { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" }, { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" }, { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" }, { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" }, ]; const DRIVERS = [ { id: "d1", name: "Martin Hale", depot: "north" }, { id: "d2", name: "Nadia Petrova", depot: "north" }, { id: "d3", name: "Owen Carter", depot: "north" }, { id: "d4", name: "Lucia Romero", depot: "river" }, { id: "d5", name: "Ben Adeyemi", depot: "river" }, { id: "d6", name: "Hanna Berg", depot: "east" }, { id: "d7", name: "Yusuf Demir", depot: "east" }, { id: "d8", name: "Clara Wendt", depot: "east" }, ] as const; const CUSTOMERS = [ "Holloway Garden Supplies", "Marlow & Finch Books", "Oakridge Pharmacy", "Brixley Cycles", "Tamsin's Bakery", "Northfold Office", "Pellham Hardware", "Greywick Studio", "Juniper Lane Florist", "Ashcombe Dental", ] as const; interface Stop { id: string; shipment: string; customer: string; /** The window the customer was promised, `[from, to]`. */ window: readonly [number, number]; /** When the tour plans to be there. */ arrival: number; } interface Tour { id: string; vehicle: string; driver: string; depot: string; /** Leaves the depot, loaded; back at the depot. */ from: number; to: number; /** Minutes of loading before `from`. */ loading: number; km: number; stops: readonly Stop[]; } /** One tour per vehicle. Stops every half hour or so, each with a two-hour window; every seventh arrival misses its window - the late ones a dispatcher looks for. */ function tour(index: number): Tour { const r = random(700 + index); const vehicle = VEHICLES[index]!; const truck = vehicle.type === "truck"; const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30); const count = truck ? 5 : 8 + Math.floor(r() * 4); const stops: Stop[] = []; let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE; for (let i = 0; i < count; i++) { const n = index * 20 + i; const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2; /* A late stop's window closed half an hour or more before the arrival. */ const late = i >= 2 && n % 7 === 3; const halfHour = 30 * MINUTE; const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot); stops.push({ id: `s-${index + 1}-${i + 1}`, shipment: `FP-${(1_004_210 + n * 13).toString()}`, customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!, window: [windowFrom, windowFrom + 2 * 60 * MINUTE], arrival, }); arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE; } return { id: `T-${String(index + 1).padStart(2, "0")}`, vehicle: vehicle.id, driver: DRIVERS[index]!.id, depot: vehicle.depot, from, to: arrival + 30 * MINUTE, loading: truck ? 45 : 30, km: Math.round((truck ? 140 : 70) + r() * 40), stops, }; } const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index)); const DAY = 24 * 60 * MINUTE; /* Today's tours in the world, laid on tomorrow. */ const DWELL = 10 * MINUTE; const QUARTER = 15 * MINUTE; const tomorrow = (hours: number, minutes = 0) => new Date(2026, 2, 18, hours, minutes).getTime(); const COLORS = [ "light-dark(#2563eb, #6b9bff)", "light-dark(#0d9488, #3cc7b8)", "light-dark(#7c3aed, #a98bfa)", "light-dark(#c2410c, #f08a52)", ]; const TASKS: readonly Task[] = TOURS.map((tour, i) => ({ id: tour.id, name: `Tour ${tour.id}`, color: COLORS[i % COLORS.length]!, })); /* A loading bar before the tour leaves, then one bar per stop. */ const START: readonly Subtask[] = TOURS.flatMap((tour) => [ { id: `${tour.id}-load`, task: tour.id, lane: tour.vehicle, from: tour.from + DAY - tour.loading * MINUTE, to: tour.from + DAY, name: "Loading", appearance: ["muted"] as const, }, ...tour.stops.map((stop) => ({ id: stop.id, task: tour.id, lane: tour.vehicle, from: stop.arrival + DAY, to: stop.arrival + DAY + DWELL, name: stop.customer, })), ]); /* The drive from one stop to the next is a dependency whose lag is the driving time. */ const DRIVES: readonly Dependency[] = TOURS.flatMap((tour) => { const bars = START.filter((one) => one.task === tour.id); return bars.slice(1).map((next, i) => ({ id: `${next.id}-drive`, from: bars[i]!.id, to: next.id, lag: next.from - bars[i]!.to, })); }); const WINDOW_CLOSES = new Map( TOURS.flatMap((tour) => tour.stops.map((stop) => [stop.id, stop.window[1] + DAY] as const)), ); /* A plate is one word, and so is its tie to the type: a lane header that takes two lines must not tear "FP 377" from its "K". */ const plate = (text: string) => text.replaceAll(" ", "\u00a0"); const plateOf = (vehicle: string) => VEHICLES.find((one) => one.id === vehicle)?.plate ?? vehicle; const driverOf = (driver: string) => DRIVERS.find((one) => one.id === driver)?.name ?? driver; export default function TourPlan() { const { Schedule, Lane, LaneGroup, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [tomorrow(5, 30), tomorrow(15)] } }); const [plan, setPlan] = useState(START); const apply = (intent: Intent) => setPlan((current) => [intent, ...ripple(current, DRIVES, intent)].reduce( (data, change) => data.map((bar) => applyIntent(bar, change)), current, ), ); /* Per tour, its stops that arrive after their window closed, the latest first. */ const late = TOURS.map((tour) => ({ tour, stops: plan .filter((bar) => bar.task === tour.id) .map((bar) => ({ bar, by: bar.from - (WINDOW_CLOSES.get(bar.id) ?? Infinity) })) .filter((one) => one.by > 0) .sort((a, b) => b.by - a.by), })) .filter((one) => one.stops.length > 0) .sort((a, b) => b.stops[0]!.by - a.stops[0]!.by); return (
bar.name ?? ""} > {DEPOTS.map((depot) => ( {VEHICLES.filter((one) => one.depot === depot.id).map((vehicle) => ( ))} ))}
Drag a stop later and the rest of its tour follows; the drive between two stops never gets shorter. {late.length === 0 ? "Every stop arrives inside its window" : `${late.length} ${late.length === 1 ? "tour misses" : "tours miss"} a delivery window`} {late.map(({ tour, stops }, i) => ( {`${Math.round(stops[0]!.by / MINUTE)} min late`} {`${tour.id} on ${plateOf(tour.vehicle)} (${driverOf(tour.driver)}): ${stops.map((one) => one.bar.name).join(", ")}`} ))}
); } ``` ### Plan the sprint around leave A team lead checks the running sprint against holidays, sick days and training, and moves the work that lands on someone who is away. 1. The sprint and its goal, as the team agreed it at planning. 2. People as lanes in their two teams; the axis leaves nights and weekends out. 3. Leave, sickness and training, hatched on the person's lane - a drag does not put work into it. 4. Work planned into someone's absence, listed from the same data: drag it onto a teammate and it leaves the list. Built from: First schedule, Lane groups, Blocked time, Time axis and calendar, Where a subtask may go, Findings as data, Alert, Badge. ```tsx import { useState } from "react"; import { Alert, Badge, Stack, Text } from "@umriss-ui/core"; import { applyIntent, findings, 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(); /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */ const NOW = at(17, 10, 30); 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)" }, ]; interface Sprint { id: string; name: string; from: number; to: number; goal: string; } /** Two weeks each, Monday 09:00 to the second Friday 17:00. */ const SPRINTS: readonly Sprint[] = [ { name: "Sprint 12", day: 9 - 28, goal: "Shop checkout on the new design" }, { name: "Sprint 13", day: 9 - 14, goal: "Booking app in the stores' beta" }, { name: "Sprint 14", day: 9, goal: "Member portal sign-in and profile" }, { name: "Sprint 15", day: 23, goal: "Shop search and filters" }, { name: "Sprint 16", day: 37, goal: "Booking reminders" }, ].map(({ name, day, goal }) => ({ id: name.toLowerCase().replace(" ", "-"), name, from: at(day), to: at(day + 11, 17), goal })); /** 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 SPRINT = SPRINTS.find((one) => one.id === "sprint-14")!; /* The working days of the sprint: the axis shows these and nothing else. */ const WORKING_DAYS = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20].map((day) => ({ from: at(day, 8), to: at(day, 18), })); /* Added after planning, before anyone looked at the leave table. */ const START: readonly Subtask[] = [ ...WORK, { id: "w-117", task: "portal", lane: "arjun", from: at(18), to: at(19, 17), name: "Account deletion", }, ]; const BLOCKED: readonly BlockedTime[] = LEAVE.map((leave) => ({ id: leave.id, lane: leave.person, from: leave.from, to: leave.to, label: leave.reason, })); const teamOf = (person: string) => PEOPLE.find((one) => one.id === person)?.team; const nameOf = (person: string) => PEOPLE.find((one) => one.id === person)?.name ?? person; export default function SprintAroundLeave() { const { Schedule, Lane, LaneGroup, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [SPRINT.from, SPRINT.to] } }); const [work, setWork] = useState(START); const onIntent = (intent: Intent) => { if (intent.kind === "place") return; setWork((current) => current.map((item) => applyIntent(item, intent))); }; const clashes = findings(work, [], BLOCKED).inBlockedTime; return ( {SPRINT.name} {SPRINT.goal} 9 to 20 March
teamOf(item.lane) === teamOf(lane)} onIntent={onIntent} label={(item) => item.name ?? ""} > {(["Web", "Apps"] as const).map((team) => ( {PEOPLE.filter((one) => one.team === team).map((person) => ( ))} ))}
Hatched: away. Work moves between people of one team only.
{clashes.length === 0 ? ( ) : (
    {clashes.map((found) => { const item = work.find((one) => one.id === found.subtask); const reason = BLOCKED.find((one) => one.id === found.blocked)?.label ?? "leave"; return (
  • {`${item?.name ?? found.subtask}: ${nameOf(found.lane)} is away (${reason.toLowerCase()})`}
  • ); })}
)}
); } ``` ### Replan the day on the line A production planner at the tile works moves orders between machines during the day, lets the cascade follow and reads the findings before committing. 1. Whether moving a step pushes the steps after it along, when their dependency no longer fits. 2. The machines as lanes, each order in its colour: drag a step in time or onto another machine, stretch its lead-in or lead-out, or right-click it for the context menu. 3. What is wrong with the plan now - overlaps and violated dependencies, counted from the same data after every change. 4. The intents the schedule reported, newest first: it changes nothing itself. Built from: First schedule, Dependencies, Move and lane, Stretch, lead-in, lead-out, Ripple, Findings as data, Interactions, ContextMenu. ```tsx import { useState } from "react"; import { Checkbox, ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core"; import { applyIntent, findings, ripple, shiftTask, useSchedule } from "@umriss-ui/schedule"; import type { Dependency, Intent, ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the plant world, written out here so the example runs on its own. */ const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)]; const STATIONS = [ { id: "saw", label: "Saw 1" }, { id: "lathe-1", label: "Lathe 1" }, { id: "lathe-2", label: "Lathe 2" }, { id: "mill", label: "Mill" }, { id: "press", label: "Press 2" }, { id: "paint", label: "Paint shop" }, { id: "qa", label: "Inspection" }, ] as const; /* Colours in both schemes, as the application's own tokens would give them. */ const ORDERS: readonly Task[] = [ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" }, { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" }, { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" }, { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" }, { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" }, ]; const STEPS: readonly Subtask[] = [ { id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) }, { id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) }, { id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) }, { id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) }, { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) }, { id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) }, { id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) }, { id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) }, { id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) }, { id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) }, { id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) }, { id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) }, { id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) }, { id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) }, { id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) }, { id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) }, { id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) }, { id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) }, ]; const MOVES: readonly Dependency[] = [ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) }, { id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) }, { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) }, { id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" }, { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) }, /* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) }, { id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) }, { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" }, { id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) }, { id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) }, { id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) }, { id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" }, ]; /* The whole recipe, as an application writes it. The plan lives in the application's state. The schedule draws it, a drag reports intents, and the application applies them - here, if asked, with the cascade `ripple` computes over the successors. The findings below come from the same data and change with every drop. A right-click opens `ContextMenu` from @umriss-ui/core at the pointer: the schedule reports the interaction with its target and position and knows nothing of the menu. The entries act on the data the same way a drag does - through intents; moving the whole order is `shiftTask`, one move per stop. */ const QUARTER = 15 * 60_000; type Menu = { interaction: ScheduleInteraction; subtask: Subtask | null }; type Plan = { steps: readonly Subtask[]; log: readonly string[] }; const START: Plan = { steps: STEPS, log: [] }; /* One drop can report two intents - a move and a lane - one after the other. Each is applied to the plan as the previous one left it: a functional update, not the plan this render happened to see. */ function applied(plan: Plan, intent: Intent, cascade: boolean): Plan { const pushed = cascade ? ripple(plan.steps, MOVES, intent) : []; const steps = [intent, ...pushed].reduce((data, change) => data.map((step) => applyIntent(step, change)), plan.steps); const subject = intent.kind === "place" ? intent.item : intent.subtask; const line = `${intent.kind} ${subject}${pushed.length > 0 ? `, pushed ${pushed.length}` : ""}`; return { steps, log: [line, ...plan.log].slice(0, 4) }; } export default function ReplanTheDay() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: DAY_OF_PLAN } }); const [plan, setPlan] = useState(START); const [cascade, setCascade] = useState(true); const [menu, setMenu] = useState(null); const apply = (intent: Intent) => setPlan((current) => applied(current, intent, cascade)); const onInteraction = (interaction: ScheduleInteraction) => { if (interaction.type !== "contextmenu") return; const subtask = interaction.hit.kind === "subtask" ? interaction.hit.subtask : null; setMenu({ interaction, subtask }); }; const found = findings(plan.steps, MOVES); const target = menu?.subtask ?? null; return (
setCascade(e.target.checked)} />
{STATIONS.map((station) => ( ))}
{ if (!open) setMenu(null); }} ariaLabel={target !== null ? `Actions for ${target.id}` : "Actions for the plan"} > {target !== null ? ( <> apply({ kind: "move", subtask: target.id, from: target.from - QUARTER, to: target.to - QUARTER })}> Earlier by a quarter hour apply({ kind: "move", subtask: target.id, from: target.from + QUARTER, to: target.to + QUARTER })}> Later by a quarter hour shiftTask(plan.steps, target.task, QUARTER).forEach(apply)}> The whole order later by a quarter hour apply({ kind: "leadIn", subtask: target.id, leadIn: 0 })} disabled={(target.leadIn ?? 0) === 0}> Remove the lead-in ) : ( setPlan(START)}>Reset the plan )} {found.overlaps.length} {found.overlaps.length === 1 ? "overlap" : "overlaps"}, {found.violatedDependencies.length}{" "} {found.violatedDependencies.length === 1 ? "violated dependency" : "violated dependencies"} {plan.log.length === 0 ? "Drag a subtask, or right-click one" : plan.log.join(" · ")}
); } ``` ## Getting started What an application installs and hands over before the first plan. ### Installation Install the schedule with its two peers and hand it plain data: lanes, tasks, subtasks and dependencies. It draws them and reports what a planner wants to change. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` ```sh npm install @umriss-ui/schedule @umriss-ui/core @umriss-ui/charts ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/installation/ The command brings its two peers along: core for the formats and the wording, charts for the time scale and the operating calendar ([ADR-0022](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0022-the-schedule-is-a-package-that-depends-on-core-and-charts.md)). React 19 or newer is a peer as well and stays the application's own. The stylesheets load themselves - each package's script imports its own, so there is nothing to import by hand; `@umriss-ui/schedule/styles.css` stays exported for setups that link stylesheets themselves. The plan is plain data ([ADR-0023](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0023-editing-is-controlled-the-schedule-reports-intents.md)). A lane is a person, a vehicle or a room, declared in JSX by its id. A task is an id, a colour and a name; a subtask is a main time on one lane, belonging to a task, with an optional lead-in and lead-out; a dependency joins two subtasks of one task with a lag. Times are epoch milliseconds. The schedule reports, the application decides. It draws the data it is given and changes none of it: a drag ends in an intent reported to `onIntent`, and only the application's own state update moves a bar. Without `intents` the schedule is read-only - see [Move and lane](#/move-and-lane). #### Examples ##### The smallest schedule Two people, one project, one dependency: lanes in JSX, the rest as plain arrays handed to `Subtasks` and `Dependencies`. ```tsx import { 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 tasks: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }]; const subtasks: Subtask[] = [ { id: "design", task: "portal", lane: "noah", from: at(9), to: at(11), leadOut: min(15) }, { id: "build", task: "portal", lane: "arjun", from: at(12), to: at(15, 30), leadIn: min(30) }, ]; const dependencies: Dependency[] = [{ id: "handover", from: "design", to: "build", lag: min(15) }]; export default function SmallestSchedule() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(8), at(16)] } }); return ( ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### First schedule Work on lanes over time, with a day band above and time ticks that follow the zoom (also called a Gantt chart, resource plan or timeline). Reach for it when a planner needs to see who or what is busy when, and what does not fit. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/schedule/ Lanes stand in the order they are declared; a subtask on a lane that is not declared is not drawn. Declare `Dependencies` before `Subtasks` and its lines run beneath the bars. What cannot work is drawn, never repaired: two subtasks on one lane at once, a dependency whose lag does not fit, work in blocked time. [Findings as data](#/findings) returns the same list for the application to act on. The plot is one tab stop. The keys walk the subtasks as the pointer would hover them, the tooltip names the active one, and a live region reads it once the keys rest ([ADR-0030](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0030-a-chart-is-one-tab-stop-that-walks-its-hits.md), [ADR-0033](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0033-the-schedules-keys-walk-past-the-view.md)). #### Examples ##### A day of tours Vehicles as lanes, tours as tasks: each leg is a subtask with loading as its lead-in, each handover a dependency with a transfer time. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* Two findings stand in this plan on purpose: two tours claim van FP 214 K at 10:00, and the Old Town tour's handover to the e-van is fifteen minutes short. They are drawn and marked; nothing moves them. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const DAY: readonly [number, number] = [at(5, 30), at(18)]; const VEHICLES = [ { id: "truck", label: "Truck FP 118 R" }, { id: "van-1", label: "Van FP 214 K" }, { id: "van-2", label: "Van FP 402 R" }, { id: "e-van", label: "E-van FP 377 K" }, ]; const TOURS: readonly Task[] = [ { id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" }, { id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" }, { id: "t-04", name: "T-04 Ringway", color: "light-dark(#7c3aed, #a98bfa)" }, ]; const LEGS: readonly Subtask[] = [ { id: "t-01-1", task: "t-01", lane: "truck", from: at(6), to: at(7), leadIn: min(15), leadOut: min(10) }, { id: "t-01-2", task: "t-01", lane: "van-1", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { id: "t-03-1", task: "t-03", lane: "van-2", from: at(6, 30), to: at(8), leadIn: min(30), leadOut: min(15) }, { id: "t-03-2", task: "t-03", lane: "van-1", from: at(10), to: at(11, 30), leadIn: min(15) }, { id: "t-03-3", task: "t-03", lane: "e-van", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, { id: "t-04-2", task: "t-04", lane: "van-2", from: at(9, 30), to: at(10, 45), leadIn: min(25) }, { id: "t-04-3", task: "t-04", lane: "e-van", from: at(14, 45), to: at(16), leadIn: min(15), leadOut: min(15) }, ]; const HANDOVERS: readonly Dependency[] = [ { id: "h-01-1", from: "t-01-1", to: "t-01-2", lag: min(10) }, { id: "h-03-1", from: "t-03-1", to: "t-03-2", lag: min(45) }, { id: "h-04-2", from: "t-04-2", to: "t-04-3", lag: min(60), arrives: "main" }, /* Leaves the van at 11:30 and has ten minutes to reach the e-van's loading at 11:40 - the transfer takes twenty-five. */ { id: "h-03-2", from: "t-03-2", to: "t-03-3", lag: min(25) }, ]; export default function FirstSchedule() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: DAY } }); return ( {VEHICLES.map((vehicle) => ( ))} ); } ``` ##### Start from an empty plan With nothing planned yet, `Subtasks` gets an empty array: the lanes and the time axis still stand, ready for the first drop. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (day: number, hours: number) => new Date(2026, 2, day, hours).getTime(); const PROJECTS: Task[] = []; const WORK: Subtask[] = []; const PEOPLE = [ { id: "hana", label: "Hana Sato" }, { id: "kofi", label: "Kofi Mensah" }, { id: "freya", label: "Freya Olsen" }, ]; export default function NoWorkYet() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(23, 8), at(27, 18)] } }); return ( {PEOPLE.map((person) => ( ))} ); } ``` ##### Plan a day in the machine shop The full case: seven stations, six orders and their transports, the present marked and every order named in its bars. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Dependency, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the plant world, written out here so the example runs on its own. */ const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)]; const STATIONS = [ { id: "saw", label: "Saw 1" }, { id: "lathe-1", label: "Lathe 1" }, { id: "lathe-2", label: "Lathe 2" }, { id: "mill", label: "Mill" }, { id: "press", label: "Press 2" }, { id: "paint", label: "Paint shop" }, { id: "qa", label: "Inspection" }, ] as const; /* Colours in both schemes, as the application's own tokens would give them. */ const ORDERS: readonly Task[] = [ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" }, { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" }, { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" }, { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" }, { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" }, ]; const STEPS: readonly Subtask[] = [ { id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) }, { id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) }, { id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) }, { id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) }, { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) }, { id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) }, { id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) }, { id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) }, { id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) }, { id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) }, { id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) }, { id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) }, { id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) }, { id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) }, { id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) }, { id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) }, { id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) }, { id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) }, ]; const MOVES: readonly Dependency[] = [ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) }, { id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) }, { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) }, { id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" }, { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) }, /* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) }, { id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) }, { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" }, { id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) }, { id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) }, { id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) }, { id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" }, ]; const NAMES = new Map(ORDERS.map((order) => [order.id, order.name ?? order.id])); const HALF_PAST_TEN = new Date(2026, 2, 17, 10, 30).getTime(); export default function MachinePlan() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: DAY_OF_PLAN } }); return ( NAMES.get(step.task) ?? step.task} > {STATIONS.map((station) => ( ))} ); } ``` #### Keyboard | Key | Action | |---|---| | `Tab` | Moves the focus into the plot; the first subtask in view becomes the active one. | | `← / →` | The previous or next subtask on the same lane. | | `↑ / ↓` | The nearest subtask on the lane above or below. | | `Home / End` | The first or last subtask on the lane. | | `PageUp / PageDown` | Along the lane by about a tenth of the visible span. | | `] or t` | Out along the active subtask's dependency; pressed again, on to the subtask it arrives at. | | `[ or Shift+T` | Back along the dependency to the subtask it leaves. | | `Enter / Space` | Selects the active subtask's task, as a click does. | | `Escape` | Lets go of the active subtask. | | `Alt+← / Alt+→` | Proposes a move by one step of the raster, where the application handles `move`. | | `Alt+Shift+← / →` | Proposes a new end, where the application handles `stretch`. | #### Accessibility The schedule is a figure named by `ariaLabel`, and its plot one tab stop with the role `application`, read as "schedule" (`aria-roledescription`), named alike and described by a summary: the lanes, the subtasks in view, the visible span, the findings by kind, then the keys. The canvas is hidden from a reader; the lane headers are text, and a group's fold button is named "Fold group" or "Unfold group" with the group's label. `ariaLabel` is required - name what the plan shows, "Plan of week 38". When a key comes to rest, a polite live region beside the plot reads the active subtask: its lane, task and name, the day and the times, and every finding on it by name - an overlap, a dependency short by how much, blocked time. On a dependency it reads the task, the lag and where the line runs. A pointer announces nothing; [Findings as data](#/findings) gives the application the same findings to list. The words are core's wording, so under `@umriss-ui/core/wording/de` the plot is read as "Belegungsplan". Under forced colours the plot paints itself in the system colours: the work in the text colour, told apart by lane and label; the active subtask in the selection colour; findings marked by a dash; grips and the ghost's label outlined. With reduced motion a group's chevron turns without its movement. #### API ##### `ScheduleProps` | Prop | Type | Default | Description | |---|---|---|---| | `ref` | `Ref<`[`ScheduleHandle`](#type-ScheduleHandle)`>` | — | Receives the `ScheduleHandle` - not an element.
Shown in: [Place a mark of your own (The handle)](#/handle/place-a-mark) | | `height` | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's.
Shown in: [A day of tours](#/schedule/first-schedule), [Start from an empty plan](#/schedule/no-work-yet), [Plan a day in the machine shop](#/schedule/machine-plan) and 78 more | | `laneHeight` | `number` | `44` | Height of one lane in pixels.
Shown in: [Scroll more lanes than fit (Lanes)](#/lane/many-lanes), [Measure a plan (Benchmark)](#/benchmark/benchmark) | | `headerWidth` | `number` | `160` | Width of the lane headers in pixels - at most 40 % of the schedule, so that on a phone the plot keeps the larger part.
Shown in: [What a lane header says (Lanes)](#/lane/lane-headers), [Scroll more lanes than fit (Lanes)](#/lane/many-lanes), [Group lanes under a head (Lane groups)](#/lane-groups/a-group) and 1 more | | `calendar` | [`CalendarInput`](#type-CalendarInput) | `[]` | A working calendar: the intervals in which time counts. Nights and weekends outside them are cut out of the axis. Without intervals every hour counts, as on the wall clock.
Shown in: [Cut the nights out of the axis (Time axis and calendar)](#/time-axis/working-calendar), [Plan in office hours only (Time axis and calendar)](#/time-axis/office-hours), [Plan the sprint around leave (Scenarios)](#/scenarios/plan-the-sprint-around-leave) | | `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | one hour to 28 days | The narrowest and widest time span zoom may reach, in milliseconds.
Shown in: [Move through the plan (Pan and zoom)](#/pan-and-zoom/pan-and-zoom) | | `snap` | `"ticks" \| number \| `[`SnapRaster`](#type-SnapRaster)` \| false` | `"ticks"` | The raster a drag lands on: `"ticks"` - the fine band's current step -, a step in milliseconds from local midnight, a step with an offset (shifts at 06:00, 14:00 and 22:00 are eight hours offset by six), or `false` for none. It shapes the ghost and the intent, never the data.
Shown in: [Snap to a duration (Snapping)](#/snapping/snap-to-a-duration), [Snap to handover times (Snapping)](#/snapping/snap-to-handovers), [Place without snapping (Snapping)](#/snapping/no-snapping) | | `intents` | `readonly `[`IntentKind`](#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)).
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Cut the nights out of the axis (Time axis and calendar)](#/time-axis/working-calendar), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around) and 18 more | | `canMoveTo` | `(subtask: `[`Subtask`](#/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`.
Shown in: [Keep work on the lanes that fit (Where a subtask may go)](#/where-it-may-go/lanes-that-fit), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in), [Keep work out of someone's leave (Where a subtask may go)](#/where-it-may-go/around-leave) and 1 more | | `selectedTask` | `string \| null` | — | The selected task, controlled. Leave it out and the schedule keeps the selection itself.
Shown in: [Control the selection (Selection)](#/selection/control-the-selection) | | `onSelectedTaskChange` | `(task: string \| null, subtask: string \| null) => void` | — | Called when a click selects a task or clears the selection, with the subtask that was clicked - null where the click was on a dependency or on nothing. It is called again when another subtask of the same task is clicked.
Shown in: [Control the selection (Selection)](#/selection/control-the-selection) | | `placing` | [`PlacingItem`](#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.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `route` | [`DependencyRoute`](#type-DependencyRoute)
`"curve" \| "straight" \| "orthogonal"` | `"curve"` | How the dependencies are drawn: a `"curve"` that leaves and arrives forwards, a `"straight"` line, or `"orthogonal"` segments. A dependency may say otherwise for itself.
Shown in: [Straight (Routes)](#/routes/straight), [Orthogonal (Routes)](#/routes/orthogonal), [Per dependency (Routes)](#/routes/per-dependency) | | `attach` | [`DependencyAttachment`](#type-DependencyAttachment)
`"centre" \| "nearest"` | `"centre"` | Where a dependency's ends sit on their bars: the `"centre"` of both, or the `"nearest"` edge - which is the shortest line between two stops. Within one lane both mean the middle. A dependency may say otherwise for itself. It changes the picture and never a finding: whether a dependency is violated follows from its `leaves` and `arrives` alone.
Shown in: [Choose where a line touches its bars (Dependencies)](#/dependencies/attach), [Mark the ends with a dot (Dependencies)](#/dependencies/ends), [Draw one handover its own way (Dependencies)](#/dependencies/one-handover-drawn-its-own-way) and 3 more | | `ends` | [`DependencyEnds`](#type-DependencyEnds)
`"dot" \| "none"` | `"dot"` | Whether a dependency's two ends carry a dot (`"dot"`) or the line stands alone (`"none"`). The dot says where the line is anchored - a help while a plan is being read, and noise in a plan full of short moves. A dependency may say otherwise for itself.
Shown in: [Mark the ends with a dot (Dependencies)](#/dependencies/ends), [Draw one handover its own way (Dependencies)](#/dependencies/one-handover-drawn-its-own-way) | | `label` | `(subtask: `[`Subtask`](#/subtasks/type-Subtask)`) => string` | — | What stands written in a bar: a function from a subtask to a line of text, or nothing for bars without text. The text is cut off with an ellipsis where the bar is too narrow for it and left out where even that would say nothing; a bar that began before the view keeps its text at the view's edge.
Shown in: [Plan a day in the machine shop](#/schedule/machine-plan), [Plan a sprint for a team (Subtasks)](#/subtasks/a-sprint), [Write into the bars (Bar labels)](#/bar-labels/bar-labels) and 7 more | | `tooltip` | `false \| ((target: `[`ScheduleTooltipTarget`](#/tooltip/type-ScheduleTooltipTarget)`) => ReactNode)` | — | The tooltip on a hovered subtask or dependency: its order, its times, its parts and its findings. `false` switches it off; a function receives what the pointer rests on and returns content of the application's own.
Shown in: [Say what a milestone is in your own tooltip (Milestones)](#/milestones/say-what-a-milestone-is), [Write your own tooltip content (Tooltip)](#/tooltip/own-tooltip), [Show a detail panel instead (Tooltip)](#/tooltip/detail-panel-instead) and 1 more | | `now` | `boolean \| number` | `false` | A line marking the present across the lanes: `true` follows the clock by the minute, an instant fixes it there.
Shown in: [Plan a day in the machine shop](#/schedule/machine-plan), [Mark the present (Now line)](#/now-line/now-line), [Follow the clock (Now line)](#/now-line/follow-the-clock) and 2 more | | `children` | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn.
Shown in: [A day of tours](#/schedule/first-schedule), [Start from an empty plan](#/schedule/no-work-yet), [Plan a day in the machine shop](#/schedule/machine-plan) and 78 more | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onIntent` | `(intent: `[`Intent`](#type-Intent)`) => void` | — | Called once per intent when a drag ends. The data changes only if the caller changes it.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Cut the nights out of the axis (Time axis and calendar)](#/time-axis/working-calendar), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around) and 18 more | | `onInteraction` | `(interaction: `[`ScheduleInteraction`](#/interactions/type-ScheduleInteraction)`) => void` | — | Click, context menu and hover, with what the pointer is on and where. While it is set, a right-click opens no browser menu.
Shown in: [Report what the pointer is on (Interactions)](#/interactions/report-the-pointer), [Open a context menu (Interactions)](#/interactions/open-a-context-menu), [Show a detail panel instead (Tooltip)](#/tooltip/detail-panel-instead) and 3 more | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | `ariaLabel` *required* | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38".
Shown in: [A day of tours](#/schedule/first-schedule), [Start from an empty plan](#/schedule/no-work-yet), [Plan a day in the machine shop](#/schedule/machine-plan) and 78 more | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Goes to the root element. | | `style` | `CSSProperties` | — | Goes to the root element. | ##### Types on this page ###### `ScheduleHandle` What a schedule offers a caller imperatively: the arithmetic between a point on the screen and a time on a lane. Everything else is props. | Prop | Type | Default | Description | |---|---|---|---| | `positionAt` *required* | `(clientX: number, clientY: number) => { time: number; lane: string \| null } \| null` | — | The time and the lane at a client point, or null outside the plot.
Shown in: [Place a mark of your own (The handle)](#/handle/place-a-mark) | | `clientPointOf` *required* | `(time: number, lane?: string) => { x: number; y: number } \| null` | — | The client point of a time - on the middle of a lane where one is named - or null while the schedule is not on screen.
Shown in: [Place a mark of your own (The handle)](#/handle/place-a-mark) | | `visibleDomain` *required* | `() => readonly [number, number]` | — | The visible span, as two wall-clock instants. | ###### `CalendarInput` From `@umriss-ui/charts`. Every function of this module takes either: the raw list as it stands on the axis, or the calendar built from it. ```ts type CalendarInput = WorkingCalendar | readonly WorkingInterval[]; ``` ###### `ZoomLimits` The narrowest and the widest span zoom may reach. | Prop | Type | Default | Description | |---|---|---|---| | `min` *required* | `number` | — |
Shown in: [Move through the plan (Pan and zoom)](#/pan-and-zoom/pan-and-zoom) | | `max` *required* | `number` | — |
Shown in: [Move through the plan (Pan and zoom)](#/pan-and-zoom/pan-and-zoom) | ###### `SnapRaster` A raster: a step, and where it starts after local midnight. | Prop | Type | Default | Description | |---|---|---|---| | `step` *required* | `number` | — | The distance between two lines of the raster, in milliseconds.
Shown in: [Snap to handover times (Snapping)](#/snapping/snap-to-handovers) | | `offset` *required* | `number` | — | Where the raster starts after local midnight, in milliseconds: six hours for handovers at 06:00, 14:00 and 22:00.
Shown in: [Snap to handover times (Snapping)](#/snapping/snap-to-handovers) | ###### `IntentKind` The names of the intents - the editing API of the schedule. ```ts type IntentKind = Intent["kind"]; ``` ###### `PlacingItem` What the application says it is dragging in, while it drags it: the browser hands over the dragged data only on the drop, so the ghost before the drop can only come from the caller. | Prop | Type | Default | Description | |---|---|---|---| | `item` *required* | `string` | — | The caller's key for the dragged item; it comes back in the intent.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `task` *required* | `string` | — | The task the work belongs to.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `duration` *required* | `number` | — | How long its main time is.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `leadIn` | `number` | — | The lead-in it brings. | | `leadOut` | `number` | — | The lead-out it brings. | ###### `DependencyRoute` How a dependency is drawn between its two ends: a curve that leaves and arrives forwards, a straight line, or axis-parallel segments. ```ts type DependencyRoute = "curve" | "straight" | "orthogonal"; ``` ###### `DependencyAttachment` Where on its bars a dependency's line attaches: the middle of both, or the edge that faces the other stop - which is the shortest line between them. Deliberately not called an anchor: a dependency already HAS two anchors (`leaves`, `arrives`), and those decide what it connects and therefore whether it is violated. This decides where the line touches, and nothing else. The tree has an **Anchor** of its own as well (CONTEXT.md). ```ts type DependencyAttachment = "centre" | "nearest"; ``` ###### `DependencyEnds` Whether a dependency's two ends carry a dot. The dot says where the line is anchored, which is worth saying while a plan is being read and is noise in a plan full of short moves - so it is the caller's choice. ```ts type DependencyEnds = "dot" | "none"; ``` ###### `Intent` What the schedule reports when an interaction asks for a change. Every intent carries the values it asks for, not a difference: two intents of one drop - a move and a lane - give the same data in either order. ```ts type Intent = MoveIntent | StretchIntent | LeadInIntent | LeadOutIntent | LaneIntent | PlaceIntent; ``` ###### `WorkingCalendar` From `@umriss-ui/charts`. A working calendar: sorted, merged intervals with the working time accumulated before each one begins. An empty calendar is no calendar: it maps unchanged. Build once, then pass around: the mapping runs once per point, and were it to sort and accumulate every time, the calendar would be the most expensive thing about the frame. | Prop | Type | Default | Description | |---|---|---|---| | `intervals` *required* | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | | | `offsets` *required* | `readonly number[]` | — | Working time accumulated before the respective interval begins. | | `total` *required* | `number` | — | The whole working time - the axis' extent is [0, total]. | ###### `WorkingInterval` From `@umriss-ui/charts`. An interval in which time counts; thought of as half-open, but mappable from both sides at its edges: both `from` and `to` have a working time. | Prop | Type | Default | Description | |---|---|---|---| | `from` *required* | `number` | — | | | `to` *required* | `number` | — | | ###### `MoveIntent` Move the subtask in time: the main time keeps its length. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"move"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 8 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 5 more | | `from` *required* | `number` | — | The new start of the main time.
Shown in: [Push the later stops of a tour (Ripple)](#/ripple/push-the-later-stops), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `to` *required* | `number` | — | The new end of the main time.
Shown in: [Push the later stops of a tour (Ripple)](#/ripple/push-the-later-stops), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | ###### `StretchIntent` Stretch the main time at one or both of its edges. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"stretch"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `from` *required* | `number` | — | The new start of the main time. | | `to` *required* | `number` | — | The new end of the main time. | ###### `LeadInIntent` Change the lead-in on its own. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"leadIn"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `leadIn` *required* | `number` | — | The new duration of the lead-in.
Shown in: [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | ###### `LeadOutIntent` Change the lead-out on its own. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"leadOut"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `leadOut` *required* | `number` | — | The new duration of the lead-out. | ###### `LaneIntent` Put the subtask on another lane. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"lane"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `lane` *required* | `string` | — | The id of the new lane. | ###### `PlaceIntent` Put work on the plan that was not on it: the answer to a drag from outside the schedule. It carries no subtask id - there is no subtask yet; the caller creates one, with its own identity, from `subtaskFromPlace`. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"place"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `item` *required* | `string` | — | The caller's key for the dragged item, as it declared it while dragging.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `task` *required* | `string` | — | The task the work belongs to. | | `lane` *required* | `string` | — | The lane it was dropped on.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `from` *required* | `number` | — | Where its main time would start. | | `to` *required* | `number` | — | Where its main time would end. | | `leadIn` | `number` | — | The lead-in it was declared with. | | `leadOut` | `number` | — | The lead-out it was declared with. | #### Known limits - No dependency types, critical path or auto-scheduling: the application decides where work goes, the schedule shows the result and its findings ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). - No milestones yet: a point in time on a lane has no mark of its own. - A dependency joins two subtasks of one task; dependencies across tasks are not yet possible. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## Plan What the schedule draws: work on lanes, and everything a bar says besides its colour. ### Lanes One row per person, vehicle or room, named by its header and kept in the order it is declared, however many there are (also called resources). ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/lane/ A lane is declared by its id, the one subtasks name, and a label. The label is content, not a string: a name with a plate number, a status beside it. It is real text, read by a screen reader, and it stays at the left edge while the plot pans. A lane keeps its height whatever lies on it. Nothing is moved to another lane to make room: two subtasks on one lane at once are a finding (see [Overlap](#/overlap)). #### Examples ##### What a lane header says A `label` is content, not a string: here the vehicle with its plate, and a badge for the van that is off the road. ```tsx import { Badge, Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number) => new Date(2026, 2, 17, hours).getTime(); const TOURS: Task[] = [{ id: "t-02", name: "T-02 Northfold", color: "light-dark(#0d9488, #3cc7b8)" }]; const LEGS: Subtask[] = [ { id: "t-02-1", task: "t-02", lane: "v1", from: at(7), to: at(9) }, { id: "t-02-2", task: "t-02", lane: "v2", from: at(9), to: at(11) }, ]; export default function LaneHeaders() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(12)] } }); return ( Van FP 214 K } /> E-van FP 377 K Workshop } /> ); } ``` ##### Scroll more lanes than fit Twenty vans in 300 pixels: the lanes scroll with their headers, while the time bands and the header column hold still. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* `height` is the whole schedule, both bands included; `laneHeight` and `headerWidth` set the rest. */ const DEPOTS: Task[] = [ { id: "north", name: "North depot", color: "light-dark(#2563eb, #6b9bff)" }, { id: "river", name: "Riverside depot", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const VANS = Array.from({ length: 20 }, (_, i) => ({ id: `van-${i + 1}`, label: `Van ${String(i + 1).padStart(2, "0")}` })); /* One round per van, each starting a quarter hour after the one above. */ const ROUNDS: Subtask[] = VANS.map((van, i) => ({ id: `${van.id}-round`, task: i % 2 === 0 ? "north" : "river", lane: van.id, from: at(7, 15 * i), to: at(9, 15 * i), leadIn: 15 * 60_000, })); export default function ManyLanes() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(14)] } }); return ( {VANS.map((van) => ( ))} ); } ``` #### When to use something else - Lanes that belong together, folded into one row → Lane groups #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `LaneProps` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The identity subtasks name in their `lane`.
Shown in: [What a lane header says](#/lane/lane-headers), [Scroll more lanes than fit](#/lane/many-lanes), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 78 more | | `label` | `ReactNode` | `id` | What the lane header says - the person, vehicle or room. Real text, read by a screen reader.
Shown in: [What a lane header says](#/lane/lane-headers), [Scroll more lanes than fit](#/lane/many-lanes), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 77 more | ### Lane groups Teams, depots or regions over the lanes, to any depth, folded into one row when a planner wants only the part they work on. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/ A group is structure, never a lane: nothing sits on it, no finding is reported for it and no intent names it ([ADR-0025](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)). The lanes keep the order they were declared in, whatever group they are in. Folded, a group shows a miniature: every lane in it as a thin strip with its work in the tasks' colours. Dependencies still arrive at the right strip, findings are marked on the row, and a strip can be hovered and selected. Labels, appearances and grips need room and wait for the group to open. Folding is part of the schedule's view and never an intent: `folded` in `initialView` starts with groups folded, `toggleGroup`, `foldAll` and `unfoldAll` fold from outside, and `onViewChange` reports every fold the planner makes - see [View](#/view). #### Examples ##### Group lanes under a head Wrap lanes in a `LaneGroup`: a slim head names the team and counts its lanes, and its chevron folds it. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* Declared by composition: a lane never names its group, so moving a person to another team is moving a line of JSX. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const PROJECTS: Task[] = [ { id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }, { id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }, ]; const WORK: Subtask[] = [ { id: "w-102", task: "portal", lane: "arjun", from: at(7), to: at(9), leadIn: min(30) }, { id: "w-104", task: "shop", lane: "chloe", from: at(8), to: at(10, 30), leadOut: min(15) }, { id: "w-106", task: "portal", lane: "noah", from: at(10), to: at(12) }, { id: "w-108", task: "shop", lane: "eva", from: at(11, 30), to: at(14) }, ]; export default function AGroup() { const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)] } }); return ( ); } ``` ##### Nest groups to any depth A depot holds a fleet of vans and a truck; an inner group keeps its fold while the outer one closes. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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 TOURS: Task[] = [ { id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" }, { id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" }, ]; const LEGS: Subtask[] = [ { id: "l1", task: "t-01", lane: "fp-214", from: at(7), to: at(9) }, { id: "l2", task: "t-03", lane: "fp-377", from: at(8), to: at(10, 30) }, { id: "l3", task: "t-01", lane: "fp-290", from: at(9, 30), to: at(11, 30) }, { id: "l4", task: "t-03", lane: "fp-118", from: at(11), to: at(13) }, { id: "l5", task: "t-01", lane: "fp-402", from: at(12), to: at(14) }, ]; export default function Nesting() { const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)] } }); return ( ); } ``` ##### Fold groups from outside `foldAll`, `unfoldAll` and `toggleGroup` fold from outside, and `view.folded` says what is folded - start from `initialView`, go to a whole view with `setView`, keep what `onViewChange` reports. ```tsx import { Button, Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* The fold is part of the schedule's view, not of the plan: no intent, and nothing the application has to hold for the chevrons to work. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TASKS: Task[] = [{ id: "release", name: "Release 4.12", color: "light-dark(#0d9488, #3cc7b8)" }]; const WORK: Subtask[] = [ { id: "s1", task: "release", lane: "priya", from: at(7), to: at(9) }, { id: "s2", task: "release", lane: "jonas", from: at(9, 30), to: at(11) }, { id: "s3", task: "release", lane: "tomasz", from: at(11, 30), to: at(13) }, { id: "s4", task: "release", lane: "leila", from: at(13), to: at(14, 30) }, ]; export default function FoldFromOutside() { const { Schedule, Lane, LaneGroup, Subtasks, view, foldAll, unfoldAll } = useSchedule({ initialView: { domain: [at(6), at(15)], folded: ["discovery"] } }); const folded = view.folded ?? []; return ( {folded.length === 0 ? "nothing folded" : folded.join(", ")} ); } ``` ##### Read a folded group Folded, the depot shows each van as a thin strip: handovers still arrive, the double booking stays marked, and a held drag opens it. ```tsx import { useState } from "react"; import { applyIntent, findings, useSchedule } from "@umriss-ui/schedule"; import { Stack, Text } from "@umriss-ui/core"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* A drag held over the folded depot opens it for the gesture and shuts it again when the drag ends; the schedule's view never learns of it. `findings()` is computed from the data, so the count below is the same folded or not. */ 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 min = (n: number) => n * 60_000; const TOURS: Task[] = [ { id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" }, { id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" }, ]; const LEGS: Subtask[] = [ { id: "t-01-1", task: "t-01", lane: "truck", from: at(6, 30), to: at(8), leadOut: min(15) }, { id: "t-01-2", task: "t-01", lane: "van-2", from: at(9), to: at(11, 30), leadIn: min(30) }, { id: "t-03-1", task: "t-03", lane: "van-1", from: at(7), to: at(9) }, /* Claims the e-van while the Harbour tour still has it: an overlap. */ { id: "t-03-2", task: "t-03", lane: "van-2", from: at(11), to: at(13), leadIn: min(15) }, { id: "t-03-3", task: "t-03", lane: "van-3", from: at(13, 30), to: at(15) }, ]; const HANDOVERS: Dependency[] = [ /* Out of the truck, outside the depot group, into a van inside it. */ { id: "h-1", from: "t-01-1", to: "t-01-2", lag: min(20) }, { id: "h-2", from: "t-03-1", to: "t-03-2", lag: min(30) }, { id: "h-3", from: "t-03-2", to: "t-03-3", lag: min(20) }, ]; const FOUND = findings(LEGS, HANDOVERS); export default function TheMiniature() { const { Schedule, Lane, LaneGroup, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6), at(16)], folded: ["north"] } }); const [legs, setLegs] = useState(LEGS); const [last, setLast] = useState("Hold a bar over the folded depot"); return ( { if (intent.kind === "place") return; setLegs((current) => current.map((leg) => applyIntent(leg, intent))); setLast(`${intent.subtask}: ${intent.kind}`); }} > {last} {FOUND.overlaps.length} overlap and {FOUND.violatedDependencies.length} violated dependency - folded or not ); } ``` ##### Start with groups folded `folded` in `initialView` folds groups when the schedule mounts and leaves the rest to the planner: Discovery starts folded, and its chevron opens it. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* The schedule keeps the fold from here on. To store it, keep what `onViewChange` reports; to steer it, use `setView` and the setters `useSchedule` hands back. `initialView` is read once. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TASKS: Task[] = [{ id: "release", name: "Release 4.12", color: "light-dark(#0d9488, #3cc7b8)" }]; const WORK: Subtask[] = [ { id: "s1", task: "release", lane: "priya", from: at(7), to: at(9) }, { id: "s2", task: "release", lane: "jonas", from: at(9, 30), to: at(11) }, { id: "s3", task: "release", lane: "tomasz", from: at(11, 30), to: at(13) }, { id: "s4", task: "release", lane: "leila", from: at(13), to: at(14, 30) }, ]; export default function StartWithGroupsFolded() { const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)], folded: ["discovery"] } }); return ( ); } ``` #### Keyboard | Key | Action | |---|---| | `Tab` | Reaches each group's fold button - the only buttons the schedule puts in the tab order. | | `Enter / Space` | Folds or unfolds the group. | The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `LaneGroupProps` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The group's identity - what the view's `folded` names. It is never a lane: no subtask sits on it, no finding is reported for it, and no intent names it ([ADR-0025](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)).
Shown in: [Group lanes under a head](#/lane-groups/a-group), [Nest groups to any depth](#/lane-groups/nesting), [Fold groups from outside](#/lane-groups/fold-from-outside) and 5 more | | `label` | `ReactNode` | `id` | What the group's header says - the team, the depot, the region. Real text, read by a screen reader.
Shown in: [Group lanes under a head](#/lane-groups/a-group), [Nest groups to any depth](#/lane-groups/nesting), [Fold groups from outside](#/lane-groups/fold-from-outside) and 5 more | | `children` | `ReactNode` | — | `Lane`s and further `LaneGroup`s, to any depth.
Shown in: [Group lanes under a head](#/lane-groups/a-group), [Nest groups to any depth](#/lane-groups/nesting), [Fold groups from outside](#/lane-groups/fold-from-outside) and 5 more | #### Known limits - A folded group shows no summary - no utilisation band, no bar spanning its work: a computed claim would look like a drawn fact ([ADR-0025](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Subtasks The bars: a main time with an optional lead-in before it and lead-out after it, in the colour of the task they belong to (also called bookings or assignments). ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/subtasks/ Lead-in and lead-out are durations beside the main time, not times of their own: moving the subtask takes them along. They are drawn faint in the task's colour, so preparation reads as belonging to the work and as not being it, and they occupy the lane as the main time does. #### Examples ##### Main time, lead-in and lead-out Loading before a round and unloading after it: `leadIn` and `leadOut` are durations beside the main time, drawn faint in the same colour. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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: Task[] = [{ id: "t-02", name: "T-02 Northfold", color: "light-dark(#2563eb, #6b9bff)" }]; const ROUNDS: Subtask[] = [ { id: "plain", task: "t-02", lane: "van", from: at(6, 30), to: at(8) }, { id: "loaded", task: "t-02", lane: "van", from: at(9), to: at(10, 30), leadIn: min(30) }, { id: "loaded-and-unloaded", task: "t-02", lane: "van", from: at(11, 30), to: at(13), leadIn: min(20), leadOut: min(40) }, ]; export default function LeadInAndLeadOut() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(14, 30)] } }); return ( ); } ``` ##### Colour by what the task means Every subtask of a task shares its `color`, and the meaning is yours: here the two projects due this week are in the danger colour. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* A subtask whose task is missing from `tasks` is drawn muted: it is on the plan, and nobody said what it is. */ const at = (day: number, hours: number) => new Date(2026, 2, day, hours).getTime(); const PROJECTS: Task[] = [ { id: "portal", name: "Member portal", color: "var(--u-color-danger)" }, { id: "booking", name: "Booking app", color: "var(--u-color-danger)" }, { id: "intranet", name: "Intranet", color: "var(--u-color-accent)" }, ]; const WORK: Subtask[] = [ { id: "w-105", task: "portal", lane: "chloe", from: at(16, 9), to: at(17, 17) }, { id: "w-109", task: "portal", lane: "eva", from: at(18, 9), to: at(19, 17) }, { id: "w-111", task: "booking", lane: "chloe", from: at(18, 9), to: at(19, 13) }, { id: "w-113", task: "intranet", lane: "eva", from: at(16, 9), to: at(17, 13) }, { id: "w-120", task: "not-listed", lane: "chloe", from: at(19, 14), to: at(20, 17) }, ]; export default function TaskColours() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(16, 6), at(20, 20)] } }); return ( ); } ``` ##### Plan a sprint for a team Work items straight from the team's tracker: the person is the lane, the project the task, and `name` is what the tooltip calls each one. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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 SUBTASKS: Subtask[] = WORK.map((item): Subtask => ({ ...item, /* Not started yet: drawn hollow, as planned but not begun. */ ...(item.status === "to do" ? { appearance: ["provisional"] } : {}), })); const SPRINT_14: readonly [number, number] = [new Date(2026, 2, 9, 6).getTime(), new Date(2026, 2, 21).getTime()]; export default function ASprint() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: SPRINT_14 } }); return ( s.name ?? s.id}> {PEOPLE.map((person) => ( ))} ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `SubtasksProps` | Prop | Type | Default | Description | |---|---|---|---| | `data` *required* | `readonly `[`Subtask`](#type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [Plan a sprint for a team](#/subtasks/a-sprint) and 78 more | | `tasks` *required* | `readonly `[`Task`](#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.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [Plan a sprint for a team](#/subtasks/a-sprint) and 78 more | ##### `Subtask` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The caller's identity of the subtask.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [Plan a sprint for a team](#/subtasks/a-sprint) and 75 more | | `task` *required* | `string` | — | The id of the task it belongs to.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 71 more | | `lane` *required* | `string` | — | The id of the lane it sits on.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 71 more | | `from` *required* | `number` | — | Start of the main time.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 71 more | | `to` *required* | `number` | — | End of the main time.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 71 more | | `leadIn` | `number` | — | Duration of the lead-in before the main time; none is zero.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule) and 25 more | | `leadOut` | `number` | — | Duration of the lead-out after the main time; none is zero.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule) and 22 more | | `name` | `string` | — | What the subtask is called, where the schedule names it.
Shown in: [Plan a sprint for a team](#/subtasks/a-sprint), [Write into the bars (Bar labels)](#/bar-labels/bar-labels), [Join the stops of a tour (Dependencies)](#/dependencies/stops-of-a-tour) and 28 more | | `appearance` | `readonly `[`SubtaskAppearance`](#type-SubtaskAppearance)`[]` | — | What the bar says besides its colour: `"provisional"`, `"fixed"`, `"muted"`, `"open"`. Several hold at once; `"provisional"` and `"fixed"` contradict, and the later one in the list wins.
Shown in: [Plan a sprint for a team](#/subtasks/a-sprint), [Provisional (Appearances)](#/appearances/provisional), [Fixed (Appearances)](#/appearances/fixed) and 3 more | | `progress` | `number` | — | The share of the work that is done, from 0 to 1, drawn as a filled part of the bar. Without it nothing is claimed - an empty bar and a bar at zero per cent are two different statements.
Shown in: [Progress (Appearances)](#/appearances/progress), [Several at once (Appearances)](#/appearances/combinations) | ##### `Task` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The caller's identity of the task.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 70 more | | `color` *required* | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 70 more | | `name` | `string` | — | What the task is called, where the schedule names it.
Shown in: [Main time, lead-in and lead-out](#/subtasks/lead-in-and-lead-out), [Colour by what the task means](#/subtasks/task-colours), [The smallest schedule (Installation)](#/installation/smallest-schedule) and 70 more | ##### Types on this page ###### `SubtaskAppearance` The appearances a caller may give a subtask. ```ts type SubtaskAppearance = "provisional" | "fixed" | "muted" | "open"; ``` ### Bar labels A line of text inside each bar: cut off where the bar is too narrow, left out where it would say nothing, and held at the view's edge while the bar runs on. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/bar-labels/ The text lies on the main time, not on the lead-in. Its colour follows the bar: light text on a dark bar, dark text on a pale one, measured by contrast in either theme. #### Examples ##### Write into the bars `label` returns the text for each bar: here the work item's name, cut where the bar is narrow and left out of the short reviews. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* Pan into the afternoon: a bar that begins before the view keeps its text at the edge, the way the day band keeps its date. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const DAY: readonly [number, number] = [at(5, 30), at(18)]; const PEOPLE = [ { id: "arjun", label: "Arjun Mehta" }, { id: "chloe", label: "Chloe Durand" }, { id: "eva", label: "Eva Novak" }, ]; const PROJECTS: readonly Task[] = [ { id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }, { id: "shop", name: "Online shop relaunch", color: "light-dark(#7c3aed, #a98bfa)" }, { id: "booking", name: "Booking app", color: "light-dark(#4d7c0f, #8fc43e)" }, ]; const WORK: readonly Subtask[] = [ { id: "w-102", task: "portal", lane: "arjun", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15), name: "Profile page" }, { id: "w-104", task: "shop", lane: "chloe", from: at(9, 30), to: at(10, 45), leadIn: min(25), name: "Basket keeps items across devices" }, { id: "w-108", task: "portal", lane: "eva", from: at(11, 30), to: at(12, 15), name: "Test plan review" }, { id: "w-116", task: "booking", lane: "eva", from: at(15), to: at(15, 30), name: "Device check" }, ]; export default function BarLabels() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: DAY } }); return ( subtask.name ?? subtask.id} > {PEOPLE.map((person) => ( ))} ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`label`](#/schedule/ScheduleProps-label) | `(subtask: `[`Subtask`](#/subtasks/type-Subtask)`) => string` | — | What stands written in a bar: a function from a subtask to a line of text, or nothing for bars without text. The text is cut off with an ellipsis where the bar is too narrow for it and left out where even that would say nothing; a bar that began before the view keeps its text at the view's edge. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Appearances What a bar says besides its colour: provisional, fixed, muted, running on past the view, and how far the work has got. Each takes one channel of the drawing, so several on one bar stay readable. ```ts import { useSchedule, resolveAppearance } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/appearances/ `"provisional"` owns the fill (none at all), `"fixed"` the ends (a cap inside each), `"muted"` the saturation, `"open"` a fade at the edge of the view, and `progress` a rail inside the main time. The faint, outlined fill belongs to a lead-in and a lead-out and to nothing else. `"provisional"` and `"fixed"` contradict, and the later one in the list wins. [`resolveAppearance`](#/api/resolveAppearance) is that rule, exported for the application to read as well. #### Examples ##### Provisional Work pencilled in but not agreed: `"provisional"` leaves the bar hollow with a dashed outline, never pale like a lead-in. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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 PROJECTS: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "agreed", task: "portal", lane: "released", from: at(7), to: at(10), leadIn: min(30), leadOut: min(30) }, { id: "pencilled", task: "portal", lane: "draft", from: at(7), to: at(10), appearance: ["provisional"] }, ]; export default function Provisional() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( ); } ``` ##### Fixed A delivery slot the customer booked may not move: `"fixed"` sets a cap inside each end and keeps the face clear for the label. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TOURS: Task[] = [{ id: "t-05", name: "T-05 Riverside", color: "light-dark(#2563eb, #6b9bff)" }]; const LEGS: Subtask[] = [ { id: "movable", task: "t-05", lane: "movable", from: at(7), to: at(10) }, { id: "nailed", task: "t-05", lane: "nailed", from: at(7), to: at(10), appearance: ["fixed"] }, ]; export default function Fixed() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( (leg.id === "nailed" ? "T-05 · booked slot" : "T-05")} > ); } ``` ##### Muted Another team's work, shown for context: `"muted"` mixes the colour into the surface at full height, so it cannot pass for a lead-in. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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 TASKS: Task[] = [{ id: "migration", name: "Checkout migration", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "ours", task: "migration", lane: "ours", from: at(7), to: at(10), leadIn: min(30) }, { id: "theirs", task: "migration", lane: "theirs", from: at(7), to: at(10), leadIn: min(30), appearance: ["muted"] }, ]; export default function Muted() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( "Checkout migration"} > ); } ``` ##### Progress `progress` from 0 to 1 draws a rail inside the main time; left out, the bar claims nothing - which is not zero per cent. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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 PROJECTS: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "unclaimed", task: "portal", lane: "unclaimed", from: at(7), to: at(10) }, { id: "started", task: "portal", lane: "started", from: at(7), to: at(10), progress: 0.65, leadOut: min(30) }, ]; export default function Progress() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( ); } ``` ##### Open An incident still being worked runs past the view: `"open"` fades the bar at whichever edge it passes, and only there. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* The middle bar is wholly in view and carries no fade; zoom out until it passes an edge and the fade appears. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TASKS: Task[] = [{ id: "inc-1048", name: "INC-1048", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "runs-on", task: "inc-1048", lane: "runs-on", from: at(7), to: at(13), appearance: ["open"] }, { id: "whole", task: "inc-1048", lane: "whole", from: at(7), to: at(10), appearance: ["open"] }, { id: "began", task: "inc-1048", lane: "began", from: at(4), to: at(10), appearance: ["open"] }, ]; export default function Open() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( ); } ``` ##### Several at once Muted, fixed and open on one bar stay three statements, each in its own channel; of `"provisional"` and `"fixed"` the later one wins. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* A hollow bar lies on the surface, so its label takes the page's text colour; a muted one takes whatever reads on its mix. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TOURS: Task[] = [ { id: "own", name: "North depot", color: "light-dark(#2563eb, #6b9bff)" }, { id: "other", name: "Riverside depot", color: "light-dark(#c2410c, #f08a52)" }, ]; const LEGS: Subtask[] = [ /* Paler colour, capped ends, faded edge. */ { id: "all-three", task: "other", lane: "all-three", from: at(7), to: at(13), appearance: ["fixed", "muted", "open"], leadIn: 30 * 60_000, }, /* The rail lies on the surface the hollow bar leaves. */ { id: "planned", task: "own", lane: "planned", from: at(7), to: at(10), appearance: ["provisional"], progress: 0.4 }, /* `"fixed"` is the later word, so this bar is fixed. */ { id: "settled", task: "own", lane: "settled", from: at(7), to: at(10), appearance: ["provisional", "fixed"] }, /* And the other way round, which makes it provisional. */ { id: "reopened", task: "own", lane: "reopened", from: at(7), to: at(10), appearance: ["fixed", "provisional"] }, ]; /** What stands in each bar. */ const NAMES: Record = { "all-three": "T-06 · Riverside", planned: "T-02 · draft", settled: "T-02 · released", reopened: "T-02 · reopened", }; const LANES = [ { id: "all-three", label: "Other depot, fixed, runs on" }, { id: "planned", label: "Provisional, 40 per cent" }, { id: "settled", label: "provisional then fixed" }, { id: "reopened", label: "fixed then provisional" }, ]; export default function Combinations() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( NAMES[leg.id] ?? leg.id} > {LANES.map((lane) => ( ))} ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`label`](#/schedule/ScheduleProps-label) | `(subtask: `[`Subtask`](#/subtasks/type-Subtask)`) => string` | — | What stands written in a bar: a function from a subtask to a line of text, or nothing for bars without text. The text is cut off with an ellipsis where the bar is too narrow for it and left out where even that would say nothing; a bar that began before the view keeps its text at the view's edge. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`appearance`](#/subtasks/Subtask-appearance) | `readonly `[`SubtaskAppearance`](#/subtasks/type-SubtaskAppearance)`[]` | — | What the bar says besides its colour: `"provisional"`, `"fixed"`, `"muted"`, `"open"`. Several hold at once; `"provisional"` and `"fixed"` contradict, and the later one in the list wins. | | [`progress`](#/subtasks/Subtask-progress) | `number` | — | The share of the work that is done, from 0 to 1, drawn as a filled part of the bar. Without it nothing is claimed - an empty bar and a bar at zero per cent are two different statements. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Overlap Two subtasks claiming one lane at the same time (also called a double booking or conflict): both drawn, offset and marked, never packed into sub-lanes. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/overlap/ The later bar is offset a few pixels and edged, and the shared time is marked across the lane. Lead-in and lead-out occupy the lane too, so a lead-out meeting the next lead-in is an overlap. The offset stops at three levels and never leaks into the lane below. #### Examples ##### A person booked twice Both bars stay where their times put them: the later one is offset and edged, and the shared time is marked across the lane. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const PROJECTS: Task[] = [ { id: "booking", name: "Booking app", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "intranet", name: "Intranet", color: "light-dark(#be185d, #f06aa6)" }, ]; const WORK: Subtask[] = [ { id: "calendar-sync", task: "booking", lane: "kofi", from: at(9), to: at(12, 30) }, { id: "news-feed", task: "intranet", lane: "kofi", from: at(11), to: at(15) }, { id: "reminders", task: "booking", lane: "hana", from: at(9), to: at(13) }, ]; export default function DoubleBooking() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(8), at(16)] } }); return ( ); } ``` ##### Lead-out meeting lead-in Unloading and the next loading occupy the vehicle too: where a `leadOut` runs into the next `leadIn`, that is an overlap. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { 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: Task[] = [ { id: "t-04", name: "T-04 Ringway", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "t-06", name: "T-06 Riverside", color: "light-dark(#be185d, #f06aa6)" }, ]; const LEGS: Subtask[] = [ { id: "t-04-1", task: "t-04", lane: "fp-402", from: at(7), to: at(8, 30), leadOut: min(30) }, { id: "t-06-1", task: "t-06", lane: "fp-402", from: at(9, 15), to: at(10, 45), leadIn: min(30) }, ]; export default function LeadOutMeetsLeadIn() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(11, 30)] } }); return ( ); } ``` ##### Three at once Each further bar steps down a little, three levels deep at most, and every one stays inside its own lane. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const INCIDENTS: Task[] = [ { id: "inc-1046", name: "INC-1046", color: "light-dark(#2563eb, #6b9bff)" }, { id: "inc-1047", name: "INC-1047", color: "light-dark(#c2410c, #f08a52)" }, { id: "inc-1048", name: "INC-1048", color: "light-dark(#7c3aed, #a98bfa)" }, ]; const WORK: Subtask[] = [ { id: "a", task: "inc-1046", lane: "priya", from: at(8), to: at(11) }, { id: "b", task: "inc-1047", lane: "priya", from: at(9), to: at(12) }, { id: "c", task: "inc-1048", lane: "priya", from: at(10), to: at(13) }, { id: "d", task: "inc-1048", lane: "jonas", from: at(10), to: at(12) }, ]; export default function ThreeAtOnce() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(7), at(14)] } }); return ( ); } ``` #### When to use something else - The overlaps as a list to count and act on → Findings as data #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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 stacked layout on request yet: an overlap is always drawn offset, as the finding it is. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Dependencies Lines from one subtask's end to the next one's start within a task, with the lag that must pass between them (also called links or predecessors). A lag that does not fit is marked, not repaired. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/dependencies/ `leaves` and `arrives` say what a dependency connects - the main time, or the outer edge of the lead-out and the lead-in - and so decide whether it is violated. `attach`, `ends` and `route` only change how the line is drawn. Nothing moves to make a dependency fit. [Ripple](#/ripple) computes the moves that would, for the application to run or not. #### Examples ##### Join the stops of a tour Each stop is a subtask, and the drive to the next is a dependency whose `lag` is the driving time. ```tsx import { 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 TOURS: Task[] = [ { id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" }, { id: "t-02", name: "T-02 Northfold", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const STOPS: Subtask[] = [ { id: "t-01-1", task: "t-01", lane: "fp-214", from: at(7, 30), to: at(7, 50), name: "Holloway Garden Supplies" }, { id: "t-01-2", task: "t-01", lane: "fp-214", from: at(8, 15), to: at(8, 30), name: "Marlow & Finch Books" }, { id: "t-01-3", task: "t-01", lane: "fp-214", from: at(9), to: at(9, 20), name: "Oakridge Pharmacy" }, { id: "t-02-1", task: "t-02", lane: "fp-377", from: at(8), to: at(8, 15), name: "Brixley Cycles" }, { id: "t-02-2", task: "t-02", lane: "fp-377", from: at(8, 45), to: at(9, 10), name: "Tamsin's Bakery" }, ]; const DRIVES: Dependency[] = [ { id: "d-01-1", from: "t-01-1", to: "t-01-2", lag: min(20) }, { id: "d-01-2", from: "t-01-2", to: "t-01-3", lag: min(25) }, { id: "d-02-1", from: "t-02-1", to: "t-02-2", lag: min(25) }, ]; export default function StopsOfATour() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(7), at(10)] } }); return ( ); } ``` ##### Choose what a dependency connects By default the lag runs from the end of unloading to the start of loading; `leaves` and `arrives` set to `"main"` skip both. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* The anchor decides whether the dependency is violated, so it should say what really has to wait for what. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const TOURS: Task[] = [ { id: "outer", name: "T-07 East Gate", color: "light-dark(#2563eb, #6b9bff)" }, { id: "inner", name: "T-08 Canal Road", color: "light-dark(#4d7c0f, #8fc43e)" }, ]; const LEGS: Subtask[] = [ { id: "o-1", task: "outer", lane: "truck", from: at(7), to: at(8, 30), leadOut: min(30) }, { id: "o-2", task: "outer", lane: "van", from: at(10, 30), to: at(12), leadIn: min(30) }, { id: "i-1", task: "inner", lane: "truck", from: at(12), to: at(13, 30), leadOut: min(30) }, { id: "i-2", task: "inner", lane: "van", from: at(15), to: at(16), leadIn: min(30) }, ]; const HANDOVERS: Dependency[] = [ { id: "outer-handover", from: "o-1", to: "o-2", lag: min(60) }, { id: "inner-handover", from: "i-1", to: "i-2", lag: min(60), leaves: "main", arrives: "main" }, ]; export default function Anchors() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(16, 30)] } }); return ( ); } ``` ##### Choose where a line touches its bars `attach="nearest"` meets the facing edges, so a short step between neighbouring lanes stays short; `"centre"` joins the middles. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency, DependencyAttachment } from "@umriss-ui/schedule"; /* Picture only: `attach` changes no finding. Within one lane there is no nearer edge, and both mean the middle. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const TASKS: Task[] = [{ id: "release", name: "Release 4.12", color: "light-dark(#2563eb, #6b9bff)" }]; const STEPS: Subtask[] = [ { id: "build", task: "release", lane: "priya", from: at(7), to: at(8, 30), name: "Build" }, { id: "review", task: "release", lane: "ada", from: at(9), to: at(10, 30), name: "Security review" }, { id: "deploy", task: "release", lane: "sam", from: at(11), to: at(12), name: "Deploy" }, /* Back up to Priya's lane: the step whose edges `attach` decides. */ { id: "verify", task: "release", lane: "priya", from: at(12, 30), to: at(13), name: "Verify" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-review", from: "build", to: "review", lag: min(20) }, { id: "to-deploy", from: "review", to: "deploy", lag: min(20) }, { id: "to-verify", from: "deploy", to: "verify", lag: min(20) }, ]; function Variant({ attach }: { attach: DependencyAttachment }) { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(13, 30)] } }); return ( attach="{attach}" ); } export default function Attach() { return ( ); } ``` ##### Mark the ends with a dot `ends="dot"` marks where each line is anchored, a help while reading; `"none"` keeps a plan of many short handovers quiet. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency, DependencyEnds } from "@umriss-ui/schedule"; /* Picture only, like `route` and `attach`; a single dependency may say otherwise for itself. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const PROJECTS: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "design", task: "portal", lane: "noah", from: at(7), to: at(8), name: "Design" }, { id: "build", task: "portal", lane: "arjun", from: at(8, 30), to: at(9, 30), name: "Build" }, { id: "test", task: "portal", lane: "eva", from: at(10), to: at(11), name: "Test" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "design", to: "build", lag: min(15) }, { id: "to-test", from: "build", to: "test", lag: min(15) }, ]; function Variant({ ends }: { ends: DependencyEnds }) { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(11, 30)] } }); return ( ends="{ends}" ); } export default function Ends() { return ( ); } ``` ##### Show a lag that does not fit The transfer at the depot takes forty minutes and the plan leaves twenty-five: the line turns dashed in the finding colour, and nothing moves. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* Whether the van waits or the truck leaves earlier is the planner's decision - `ripple` offers the arithmetic for the first. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const TOURS: Task[] = [{ id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" }]; const LEGS: Subtask[] = [ { id: "line-haul", task: "t-03", lane: "truck", from: at(7), to: at(8, 30), leadOut: min(5) }, { id: "round", task: "t-03", lane: "van", from: at(9, 15), to: at(10, 30), leadIn: min(15) }, ]; const HANDOVERS: Dependency[] = [{ id: "transfer", from: "line-haul", to: "round", lag: min(40) }]; export default function ViolatedDependency() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(11)] } }); return ( ); } ``` ##### Draw one handover its own way A dependency's own `attach` and `ends` override the schedule's for that one line: here the sign-off joins the middles and marks its ends, the rest stay quiet. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* Picture only, like the schedule's own `attach` and `ends`: the finding follows from `leaves` and `arrives` alone. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const PROJECTS: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "design", task: "portal", lane: "noah", from: at(7), to: at(8), name: "Design" }, { id: "build", task: "portal", lane: "arjun", from: at(8, 30), to: at(9, 30), name: "Build" }, { id: "test", task: "portal", lane: "eva", from: at(10), to: at(11), name: "Test" }, { id: "sign-off", task: "portal", lane: "noah", from: at(11, 30), to: at(12), name: "Sign-off" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "design", to: "build", lag: min(15) }, { id: "to-test", from: "build", to: "test", lag: min(15) }, /* The one that matters: the designer signs off what was tested. */ { id: "to-sign-off", from: "test", to: "sign-off", lag: min(15), attach: "centre", ends: "dot" }, ]; export default function OneHandoverDrawnItsOwnWay() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(12, 30)] } }); return ( ); } ``` #### When to use something else - The shape of the line → Routes #### Keyboard | Key | Action | |---|---| | `] or t` | Out along the active subtask's dependency; pressed again, on to the subtask it arrives at. | | `[ or Shift+T` | Back along the dependency to the subtask it leaves. | The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `DependenciesProps` | Prop | Type | Default | Description | |---|---|---|---| | `data` *required* | `readonly `[`Dependency`](#type-Dependency)`[]` | — | The dependencies, as the caller holds them.
Shown in: [Join the stops of a tour](#/dependencies/stops-of-a-tour), [Choose what a dependency connects](#/dependencies/anchors), [Choose where a line touches its bars](#/dependencies/attach) and 28 more | ##### `Dependency` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The caller's identity of the dependency.
Shown in: [Join the stops of a tour](#/dependencies/stops-of-a-tour), [Choose what a dependency connects](#/dependencies/anchors), [Choose where a line touches its bars](#/dependencies/attach) and 27 more | | `from` *required* | `string` | — | The id of the subtask it leaves.
Shown in: [Join the stops of a tour](#/dependencies/stops-of-a-tour), [Choose what a dependency connects](#/dependencies/anchors), [Choose where a line touches its bars](#/dependencies/attach) and 27 more | | `to` *required* | `string` | — | The id of the subtask it arrives at.
Shown in: [Join the stops of a tour](#/dependencies/stops-of-a-tour), [Choose what a dependency connects](#/dependencies/anchors), [Choose where a line touches its bars](#/dependencies/attach) and 27 more | | `lag` *required* | `number` | — | The least time between the two anchors; zero where the successor may begin as soon as the predecessor ends.
Shown in: [Join the stops of a tour](#/dependencies/stops-of-a-tour), [Choose what a dependency connects](#/dependencies/anchors), [Choose where a line touches its bars](#/dependencies/attach) and 27 more | | `leaves` | `"main" \| "leadOut"` | `"leadOut"` | Where it leaves: at the end of the main time, or after the lead-out. The predecessor counts as ended once its lead-out is.
Shown in: [Choose what a dependency connects](#/dependencies/anchors), [Plan a day in the machine shop (First schedule)](#/schedule/machine-plan), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `arrives` | `"main" \| "leadIn"` | `"leadIn"` | Where it arrives: at the start of the main time, or before the lead-in. The lag must have run out before the successor's lead-in begins.
Shown in: [Choose what a dependency connects](#/dependencies/anchors), [A day of tours (First schedule)](#/schedule/first-schedule), [Plan a day in the machine shop (First schedule)](#/schedule/machine-plan) and 1 more | | `route` | [`DependencyRoute`](#type-DependencyRoute)
`"curve" \| "straight" \| "orthogonal"` | — | How this one is drawn, where it is not drawn like the rest. It changes the picture and never the finding: whether a dependency is violated follows from `leaves` and `arrives` alone.
Shown in: [Per dependency (Routes)](#/routes/per-dependency) | | `attach` | [`DependencyAttachment`](#type-DependencyAttachment)
`"centre" \| "nearest"` | — | Where on the bars this one's line attaches, where it does not attach like the rest. Also picture only, and not to be confused with the two anchors above: those say what the dependency connects, this says where the line touches.
Shown in: [Draw one handover its own way](#/dependencies/one-handover-drawn-its-own-way) | | `ends` | [`DependencyEnds`](#type-DependencyEnds)
`"dot" \| "none"` | — | Whether this one's ends are marked with a dot, where it is not marked like the rest. Also picture only.
Shown in: [Draw one handover its own way](#/dependencies/one-handover-drawn-its-own-way) | ##### Types on this page ###### `DependencyRoute` How a dependency is drawn between its two ends: a curve that leaves and arrives forwards, a straight line, or axis-parallel segments. ```ts type DependencyRoute = "curve" | "straight" | "orthogonal"; ``` ###### `DependencyAttachment` Where on its bars a dependency's line attaches: the middle of both, or the edge that faces the other stop - which is the shortest line between them. Deliberately not called an anchor: a dependency already HAS two anchors (`leaves`, `arrives`), and those decide what it connects and therefore whether it is violated. This decides where the line touches, and nothing else. The tree has an **Anchor** of its own as well (CONTEXT.md). ```ts type DependencyAttachment = "centre" | "nearest"; ``` ###### `DependencyEnds` Whether a dependency's two ends carry a dot. The dot says where the line is anchored, which is worth saying while a plan is being read and is noise in a plan full of short moves - so it is the caller's choice. ```ts type DependencyEnds = "dot" | "none"; ``` #### Known limits - Only finish-to-start within one task; no dependency types and no critical path ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Routes The shape of a dependency's line: a curve that leaves and arrives forwards, a straight line, or orthogonal segments. Set it for the schedule, or for one dependency. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/routes/ #### Examples ##### Curve The default: each line leaves forwards and arrives forwards, so it shows which way the work goes, even when it loops back up. ```tsx import { 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 PROJECTS: Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "layout", task: "shop", lane: "noah", from: at(7), to: at(8, 30), name: "Search results layout" }, { id: "build", task: "shop", lane: "chloe", from: at(9), to: at(10, 30), name: "Search results page" }, { id: "test", task: "shop", lane: "eva", from: at(11), to: at(12), name: "Test" }, { id: "polish", task: "shop", lane: "noah", from: at(12, 30), to: at(13), name: "Polish" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "layout", to: "build", lag: min(20) }, { id: "to-test", from: "build", to: "test", lag: min(20) }, { id: "to-polish", from: "test", to: "polish", lag: min(20) }, ]; export default function Curve() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(13, 30)] } }); return ( ); } ``` ##### Straight `route="straight"` draws the shortest line between the two ends, calm in a plan read from a distance. ```tsx import { 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 PROJECTS: Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "layout", task: "shop", lane: "noah", from: at(7), to: at(8, 30), name: "Search results layout" }, { id: "build", task: "shop", lane: "chloe", from: at(9), to: at(10, 30), name: "Search results page" }, { id: "test", task: "shop", lane: "eva", from: at(11), to: at(12), name: "Test" }, { id: "polish", task: "shop", lane: "noah", from: at(12, 30), to: at(13), name: "Polish" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "layout", to: "build", lag: min(20) }, { id: "to-test", from: "build", to: "test", lag: min(20) }, { id: "to-polish", from: "test", to: "polish", lag: min(20) }, ]; export default function Straight() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(13, 30)] } }); return ( ); } ``` ##### Orthogonal `route="orthogonal"` leaves the bar, runs across and comes in again, like a flow chart. ```tsx import { 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 PROJECTS: Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "layout", task: "shop", lane: "noah", from: at(7), to: at(8, 30), name: "Search results layout" }, { id: "build", task: "shop", lane: "chloe", from: at(9), to: at(10, 30), name: "Search results page" }, { id: "test", task: "shop", lane: "eva", from: at(11), to: at(12), name: "Test" }, { id: "polish", task: "shop", lane: "noah", from: at(12, 30), to: at(13), name: "Polish" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "layout", to: "build", lag: min(20) }, { id: "to-test", from: "build", to: "test", lag: min(20) }, { id: "to-polish", from: "test", to: "polish", lag: min(20) }, ]; export default function Orthogonal() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(13, 30)] } }); return ( ); } ``` ##### Per dependency A `route` on a single dependency overrides the schedule's: here only the loop back up to the designer is drawn orthogonal. ```tsx import { 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 PROJECTS: Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "layout", task: "shop", lane: "noah", from: at(7), to: at(8, 30), name: "Search results layout" }, { id: "build", task: "shop", lane: "chloe", from: at(9), to: at(10, 30), name: "Search results page" }, { id: "test", task: "shop", lane: "eva", from: at(11), to: at(12), name: "Test" }, { id: "polish", task: "shop", lane: "noah", from: at(12, 30), to: at(13), name: "Polish" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "layout", to: "build", lag: min(20) }, { id: "to-test", from: "build", to: "test", lag: min(20) }, /* Its own route, whatever the schedule says. */ { id: "to-polish", from: "test", to: "polish", lag: min(20), route: "orthogonal" }, ]; export default function PerDependency() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6, 30), at(13, 30)] } }); return ( ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`route`](#/schedule/ScheduleProps-route) | [`DependencyRoute`](#/schedule/type-DependencyRoute)
`"curve" \| "straight" \| "orthogonal"` | `"curve"` | How the dependencies are drawn: a `"curve"` that leaves and arrives forwards, a `"straight"` line, or `"orthogonal"` segments. A dependency may say otherwise for itself. | | [`attach`](#/schedule/ScheduleProps-attach) | [`DependencyAttachment`](#/schedule/type-DependencyAttachment)
`"centre" \| "nearest"` | `"centre"` | Where a dependency's ends sit on their bars: the `"centre"` of both, or the `"nearest"` edge - which is the shortest line between two stops. Within one lane both mean the middle. A dependency may say otherwise for itself. It changes the picture and never a finding: whether a dependency is violated follows from its `leaves` and `arrives` alone. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/dependencies/Dependency-lag) *required* | `number` | — | The least time between the two anchors; zero where the successor may begin as soon as the predecessor ends. | | [`route`](#/dependencies/Dependency-route) | [`DependencyRoute`](#/dependencies/type-DependencyRoute)
`"curve" \| "straight" \| "orthogonal"` | — | How this one is drawn, where it is not drawn like the rest. It changes the picture and never the finding: whether a dependency is violated follows from `leaves` and `arrives` alone. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ## Time The axis the work stands on: the calendar, blocked time, the now line, and moving through it. ### Time axis and calendar A day band that names the days and a time band whose ticks follow the zoom, from quarter hours to days. A working calendar cuts the hours nobody works out of the axis. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/time-axis/ The calendar is a list of intervals in which time counts, nothing more; deriving it from opening hours and holidays is the application's business. A dotted line marks each seam, and a drag that would end in removed time stops at the seam where time counts again. #### Examples ##### Show a week on one axis A week of the on-call rota: the day band names each day, and the time band's step follows the zoom. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* Data from the operations world, written out here so the example runs on its own. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); interface Engineer { id: string; name: string; team: string; } const ENGINEERS: readonly Engineer[] = [ { id: "priya", name: "Priya Raman", team: "Payments" }, { id: "jonas", name: "Jonas Keller", team: "Payments" }, { id: "ada", name: "Ada Mwangi", team: "Identity" }, { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" }, { id: "leila", name: "Leila Haddad", team: "Discovery" }, { id: "sam", name: "Sam Okafor", team: "Messaging" }, { id: "ines", name: "Ines Duarte", team: "Integrations" }, { id: "felix", name: "Felix Brandt", team: "Insights" }, ]; interface OnCall { id: string; engineer: string; rotation: "primary" | "secondary"; from: number; to: number; } /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the secondary of one day is the primary of the next. */ const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id }, { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id }, ].map(({ rotation, engineer }) => ({ id: `${rotation}-${16 + day}`, engineer, rotation, from: at(16 + day, 9), to: at(17 + day, 9), }))).flat(); const ROTATIONS: Task[] = [ { id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" }, { id: "secondary", name: "Secondary", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const DUTIES: Subtask[] = ONCALL.map((duty) => ({ id: duty.id, task: duty.rotation, lane: duty.engineer, from: duty.from, to: duty.to })); const WEEK: readonly [number, number] = [new Date(2026, 2, 16).getTime(), new Date(2026, 2, 23, 12).getTime()]; export default function AWeek() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: WEEK } }); return ( {ENGINEERS.map((engineer) => ( ))} ); } ``` ##### Cut the nights out of the axis The depot works 06:00 to 22:00: `calendar` lists those hours, the nights leave the axis, and a drag into one stops at the next morning. ```tsx import { useState } from "react"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task, Dependency } from "@umriss-ui/schedule"; /* Three days in the width of two; a dotted line marks each seam. Deriving the list from opening hours and holidays is the application's business. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, 16 + day, hours, minutes).getTime(); const OPENING_HOURS = [0, 1, 2].map((day) => ({ from: at(day, 6), to: at(day, 22) })); const TOURS: Task[] = [{ id: "t-09", name: "T-09 Line haul East", color: "light-dark(#c2410c, #f08a52)" }]; const LEGS: Subtask[] = [ { id: "line-haul", task: "t-09", lane: "truck", from: at(0, 18), to: at(0, 21, 30), leadIn: 30 * 60_000 }, { id: "round", task: "t-09", lane: "van-1", from: at(1, 6, 30), to: at(1, 11), leadIn: 15 * 60_000 }, { id: "returns", task: "t-09", lane: "van-2", from: at(1, 20), to: at(2, 9), leadOut: 30 * 60_000 }, ]; const HANDOVERS: Dependency[] = [ { id: "unload", from: "line-haul", to: "round", lag: 20 * 60_000 }, { id: "collect", from: "round", to: "returns", lag: 45 * 60_000 }, ]; export default function WorkingCalendar() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(0, 6), at(2, 22)] } }); const [legs, setLegs] = useState(LEGS); return ( setLegs((current) => current.map((leg) => applyIntent(leg, intent)))} > ); } ``` ##### Plan in office hours only Weekdays 09:00 to 17:00 as the `calendar`: a two-week sprint reads as ten working days, with the evenings and the weekend gone. ```tsx import { useSchedule } 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 WORKING_DAYS = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20]; const OFFICE_HOURS = WORKING_DAYS.map((day) => ({ from: at(day, 9), to: at(day, 17) })); const WEB_TEAM = PEOPLE.filter((person) => person.team === "Web"); const ON_THE_WEB_TEAM = new Set(WEB_TEAM.map((person) => person.id)); export default function OfficeHours() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(9, 9), at(20, 17)] } }); return ( item.name ?? item.id} > {WEB_TEAM.map((person) => ( ))} ON_THE_WEB_TEAM.has(item.lane))} tasks={PROJECTS} /> ); } ``` #### When to use something else - Time a single lane is not available → Blocked time #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`calendar`](#/schedule/ScheduleProps-calendar) | [`CalendarInput`](#/schedule/type-CalendarInput) | `[]` | A working calendar: the intervals in which time counts. Nights and weekends outside them are cut out of the axis. Without intervals every hour counts, as on the wall clock. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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)). | | [`label`](#/schedule/ScheduleProps-label) | `(subtask: `[`Subtask`](#/subtasks/type-Subtask)`) => string` | — | What stands written in a bar: a function from a subtask to a line of text, or nothing for bars without text. The text is cut off with an ellipsis where the bar is too narrow for it and left out where even that would say nothing; a bar that began before the view keeps its text at the view's edge. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Blocked time Leave, maintenance and other time a lane is not available: hatched behind the work, a finding where work covers it, and closed to a drag. ```ts import { useSchedule, findings } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/blocked-time/ Blocked time is data beside the work: one list, each interval naming its lane, as a leave table or a maintenance plan holds it. [`findings`](#/api/findings) takes the same list as its third argument. Work the data already put into blocked time is never locked there: it may be moved within it and out of it, and the finding stays until it is. #### Examples ##### Block out leave and maintenance A person on leave, a server in maintenance: `BlockedTimes` hatches that time on the lane, and work planned into it is a finding. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { findings, useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime, Subtask, Task } from "@umriss-ui/schedule"; /* Blocked time is data beside the work: one list, each interval naming its lane, as a leave table or a maintenance plan holds it. The schedule draws it behind everything on the lane - hatched, in the muted ink, in forced colours too - and a subtask that covers some of it is marked like an overlap, named in its tooltip and counted in what a screen reader hears. `findings` takes the same list as its third argument and returns every subtask in blocked time with the interval it covers. */ const day = (d: number, hours = 0) => new Date(2026, 2, 16 + d, hours).getTime(); const PEOPLE = [ { id: "anna", label: "Anna Berg" }, { id: "ben", label: "Ben Ortiz" }, { id: "chloe", label: "Chloe Lind" }, { id: "build", label: "Build server" }, ]; const PROJECTS: readonly Task[] = [ { id: "portal", name: "Customer portal", color: "light-dark(#2563eb, #6b9bff)" }, { id: "billing", name: "Billing rework", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const WORK: readonly Subtask[] = [ { id: "portal-design", task: "portal", lane: "anna", from: day(0, 9), to: day(1, 17), name: "Design" }, /* Planned into Anna's leave - the finding. */ { id: "portal-review", task: "portal", lane: "anna", from: day(2, 13), to: day(3, 12), name: "Design review" }, { id: "billing-api", task: "billing", lane: "ben", from: day(0, 9), to: day(2, 17), name: "Invoice API" }, { id: "portal-front", task: "portal", lane: "chloe", from: day(1, 9), to: day(4, 17), name: "Front end" }, { id: "billing-ci", task: "billing", lane: "build", from: day(3, 9), to: day(3, 15), name: "Release build" }, ]; const BLOCKED: readonly BlockedTime[] = [ { id: "anna-leave", lane: "anna", from: day(3, 0), to: day(5, 0), label: "Leave" }, { id: "build-maintenance", lane: "build", from: day(1, 18), to: day(2, 12), label: "Maintenance" }, ]; const time = (instant: number) => new Date(instant).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); export default function Leave() { const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [day(0, 6), day(5, 0)] } }); const found = findings(WORK, [], BLOCKED); return ( {PEOPLE.map((person) => ( ))}
    {found.inBlockedTime.map((f) => (
  • {`${f.subtask} in ${f.blocked}, ${time(f.from)} to ${time(f.to)}`}
  • ))}
); } ``` ##### Plan work around someone's leave A drag does not put work into blocked time: the ghost stays where it was allowed until the pointer is past it. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime, Intent, Subtask, Task } from "@umriss-ui/schedule"; /* Blocked time is part of where a subtask may go. Drag the review towards Anna's leave and the ghost stops short of it, the cursor says no and a line ties the ghost to the pointer; carry on past the leave, or onto Ben's lane, and it follows again. Alt with an arrow key steps over the leave instead of stopping in front of it. Work the data already put into blocked time is never locked there: it may be moved within that blocked time, and out of it. The finding stays until it is. */ const day = (d: number, hours = 0) => new Date(2026, 2, 16 + d, hours).getTime(); const PROJECTS: readonly Task[] = [{ id: "portal", name: "Customer portal", color: "light-dark(#2563eb, #6b9bff)" }]; const START: readonly Subtask[] = [ { id: "review", task: "portal", lane: "anna", from: day(0, 9), to: day(1, 12), name: "Design review" }, { id: "tests", task: "portal", lane: "ben", from: day(2, 9), to: day(3, 17), name: "Tests" }, ]; const BLOCKED: readonly BlockedTime[] = [{ id: "anna-leave", lane: "anna", from: day(2, 0), to: day(4, 0), label: "Leave" }]; export default function DragAround() { const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [day(0, 6), day(5, 0)] } }); const [work, setWork] = useState(START); const [last, setLast] = useState("Drag the design review into Anna's leave"); const onIntent = (intent: Intent) => { if (intent.kind === "place") return; setWork((current) => current.map((s) => applyIntent(s, intent))); setLast(`${intent.subtask}: ${intent.kind}`); }; return ( {last} ); } ``` ##### Check a sprint against the leave calendar The team's holidays, sick days and training become `BlockedTimes`, and [`findings`](#/api/findings) confirms that no work item runs into one. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { findings, useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime } 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 BLOCKED: BlockedTime[] = LEAVE.map((leave) => ({ id: leave.id, lane: leave.person, from: leave.from, to: leave.to, label: leave.reason })); const SPRINT_14: readonly [number, number] = [new Date(2026, 2, 9, 6).getTime(), new Date(2026, 2, 21).getTime()]; const NAME = new Map(WORK.map((item) => [item.id, item.name])); const PERSON = new Map(PEOPLE.map((person) => [person.id, person.name])); export default function TeamLeave() { const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: SPRINT_14 } }); const clashes = findings(WORK, [], BLOCKED).inBlockedTime; return ( {PEOPLE.map((person) => ( ))} {clashes.length === 0 ? "No work falls into anyone's leave." : clashes.map((clash) => `${NAME.get(clash.subtask)} (${PERSON.get(WORK.find((w) => w.id === clash.subtask)?.lane ?? "")})`).join(" · ")} ); } ``` #### When to use something else - Hours nobody works, on every lane → Time axis and calendar #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `BlockedTimesProps` | Prop | Type | Default | Description | |---|---|---|---| | `data` *required* | `readonly `[`BlockedTime`](#type-BlockedTime)`[]` | — | The blocked time of the lanes - leave, maintenance, unavailability -, as the caller holds it: one list, each interval naming its lane.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | ##### `BlockedTime` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The caller's identity of the interval - what a finding names.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | | `lane` *required* | `string` | — | The id of the lane it blocks.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | | `from` *required* | `number` | — | Where it begins.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | | `to` *required* | `number` | — | Where it ends.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | | `label` | `string` | — | Why the lane is blocked - "Leave", "Maintenance" -, where the schedule names it.
Shown in: [Block out leave and maintenance](#/blocked-time/leave), [Plan work around someone's leave](#/blocked-time/drag-around), [Check a sprint against the leave calendar](#/blocked-time/team-leave) and 5 more | ### Milestones A point in time on a lane - a handover, a release, a deadline - drawn as a diamond above the work, reached by the keys and named in the tooltip. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/milestones/ A milestone is data beside the work: one list, each point naming its lane and the instant it stands at (`{ id, lane, at, label? }`). It has no duration, no task and no tone; it is not dragged and reports no intent, and work beside it is never a finding. The keys walk to it in time order among the subtasks of its lane, the tooltip names it by its label and time, and the live region reads its lane, label and time. It is named by its label, or its id without one. #### Examples ##### Mark a handover and a release A point in time on a lane is a mark, not a bar: `Milestones` draws a diamond at `at`, above the work. ```tsx import { Stack } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Milestone, Subtask, Task } from "@umriss-ui/schedule"; /* A milestone is data beside the work: one list, each point naming its lane and the instant it stands at, as a release calendar holds it. It is drawn above the bars - the design handover below stands on the end of the design bar and is still seen - and it has no duration, no task and no colour of its own. Hover it and the tooltip names it and says when; the keys reach it as they reach the work, with the arrows in time order along the lane, and the live region reads its lane, its label and its time. It is not dragged and reports no intent. */ const day = (d: number, hours = 0) => new Date(2026, 2, 16 + d, hours).getTime(); const PROJECTS: readonly Task[] = [ { id: "portal", name: "Customer portal", color: "light-dark(#2563eb, #6b9bff)" }, { id: "billing", name: "Billing rework", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const WORK: readonly Subtask[] = [ { id: "portal-design", task: "portal", lane: "anna", from: day(0, 9), to: day(1, 17), name: "Design" }, { id: "portal-front", task: "portal", lane: "chloe", from: day(1, 9), to: day(3, 17), name: "Front end" }, { id: "billing-api", task: "billing", lane: "ben", from: day(0, 9), to: day(2, 17), name: "Invoice API" }, ]; const MILESTONES: readonly Milestone[] = [ { id: "design-handover", lane: "anna", at: day(1, 17), label: "Design handover" }, { id: "api-freeze", lane: "ben", at: day(2, 17), label: "API freeze" }, { id: "release", lane: "chloe", at: day(4, 12), label: "Release 2.0" }, ]; export default function HandoversAndReleases() { const { Schedule, Lane, Subtasks, Milestones } = useSchedule({ initialView: { domain: [day(0, 6), day(5, 0)] } }); return ( ); } ``` ##### Say what a milestone is in your own tooltip Over a milestone the target is `milestone`: the point itself, with its `label` and `at`, for a tooltip of your own. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Milestone, ScheduleTooltipTarget, Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const PROJECTS: readonly Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: readonly Subtask[] = [ { id: "build", task: "portal", lane: "arjun", from: at(7), to: at(12), name: "Build" }, { id: "test", task: "portal", lane: "eva", from: at(12, 30), to: at(15), name: "Test" }, ]; const MILESTONES: readonly Milestone[] = [{ id: "code-freeze", lane: "arjun", at: at(12), label: "Code freeze" }]; const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); function Own({ target }: { target: ScheduleTooltipTarget }) { if (target.kind === "dependency") return null; if (target.kind === "subtask") return {target.subtask.name}; return ( {target.milestone.label ?? target.milestone.id} Test may begin from {time(target.milestone.at)} ); } export default function SayWhatAMilestoneIs() { const { Schedule, Lane, Subtasks, Milestones } = useSchedule({ initialView: { domain: [at(6, 30), at(15, 30)] } }); return ( }> ); } ``` #### When to use something else - Time a lane is not available, as a span → Blocked time #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `MilestonesProps` | Prop | Type | Default | Description | |---|---|---|---| | `data` *required* | `readonly `[`Milestone`](#type-Milestone)`[]` | — | The milestones of the lanes, as the caller holds them: one list, each naming its lane and the instant it stands at.
Shown in: [Mark a handover and a release](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip](#/milestones/say-what-a-milestone-is) | ##### `Milestone` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | The caller's identity of the milestone.
Shown in: [Mark a handover and a release](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip](#/milestones/say-what-a-milestone-is) | | `lane` *required* | `string` | — | The id of the lane it stands on.
Shown in: [Mark a handover and a release](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip](#/milestones/say-what-a-milestone-is) | | `at` *required* | `number` | — | The instant it stands at.
Shown in: [Mark a handover and a release](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip](#/milestones/say-what-a-milestone-is) | | `label` | `string` | — | What it is called, where the schedule names it; without it, its id.
Shown in: [Mark a handover and a release](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip](#/milestones/say-what-a-milestone-is) | ### Now line A line across the lanes at the present moment, so that whatever should have happened by now stands to its left. It follows the clock, or stays at a fixed instant for a replay. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/now-line/ #### Examples ##### Mark the present Pass an instant to `now` and a line crosses the lanes there: a dispatcher at 10:30 sees which tours are under way. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* Data from the logistics world, written out here so the example runs on its own. */ /** A small LCG - the same numbers on every computer. */ function random(seed: number): () => number { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; } const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const MINUTE = 60_000; /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */ const NOW = at(10, 30); interface Vehicle { id: string; plate: string; type: "van" | "e-van" | "truck"; /** Payload, in kg. */ capacity: number; depot: string; } const VEHICLES: readonly Vehicle[] = [ { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" }, { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" }, { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" }, { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" }, { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" }, { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" }, { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" }, { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" }, ]; const DRIVERS = [ { id: "d1", name: "Martin Hale", depot: "north" }, { id: "d2", name: "Nadia Petrova", depot: "north" }, { id: "d3", name: "Owen Carter", depot: "north" }, { id: "d4", name: "Lucia Romero", depot: "river" }, { id: "d5", name: "Ben Adeyemi", depot: "river" }, { id: "d6", name: "Hanna Berg", depot: "east" }, { id: "d7", name: "Yusuf Demir", depot: "east" }, { id: "d8", name: "Clara Wendt", depot: "east" }, ] as const; const CUSTOMERS = [ "Holloway Garden Supplies", "Marlow & Finch Books", "Oakridge Pharmacy", "Brixley Cycles", "Tamsin's Bakery", "Northfold Office", "Pellham Hardware", "Greywick Studio", "Juniper Lane Florist", "Ashcombe Dental", ] as const; interface Stop { id: string; shipment: string; customer: string; /** The window the customer was promised, `[from, to]`. */ window: readonly [number, number]; /** When the tour plans to be there. */ arrival: number; } interface Tour { id: string; vehicle: string; driver: string; depot: string; /** Leaves the depot, loaded; back at the depot. */ from: number; to: number; /** Minutes of loading before `from`. */ loading: number; km: number; stops: readonly Stop[]; } /** One tour per vehicle. Stops every half hour or so, each with a two-hour window; every seventh arrival misses its window - the late ones a dispatcher looks for. */ function tour(index: number): Tour { const r = random(700 + index); const vehicle = VEHICLES[index]!; const truck = vehicle.type === "truck"; const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30); const count = truck ? 5 : 8 + Math.floor(r() * 4); const stops: Stop[] = []; let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE; for (let i = 0; i < count; i++) { const n = index * 20 + i; const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2; /* A late stop's window closed half an hour or more before the arrival. */ const late = i >= 2 && n % 7 === 3; const halfHour = 30 * MINUTE; const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot); stops.push({ id: `s-${index + 1}-${i + 1}`, shipment: `FP-${(1_004_210 + n * 13).toString()}`, customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!, window: [windowFrom, windowFrom + 2 * 60 * MINUTE], arrival, }); arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE; } return { id: `T-${String(index + 1).padStart(2, "0")}`, vehicle: vehicle.id, driver: DRIVERS[index]!.id, depot: vehicle.depot, from, to: arrival + 30 * MINUTE, loading: truck ? 45 : 30, km: Math.round((truck ? 140 : 70) + r() * 40), stops, }; } const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index)); /* A fixed instant suits a replay and a picture that must hold still; in a live application `now` alone follows the clock - the next example. */ const COLORS = ["light-dark(#2563eb, #6b9bff)", "light-dark(#0d9488, #3cc7b8)", "light-dark(#c2410c, #f08a52)", "light-dark(#7c3aed, #a98bfa)"]; const TASKS: Task[] = TOURS.map((tour, i) => ({ id: tour.id, name: tour.id, color: COLORS[i % COLORS.length]! })); const ROUNDS: Subtask[] = TOURS.map((tour) => ({ id: tour.id, task: tour.id, lane: tour.vehicle, from: tour.from, to: tour.to, leadIn: tour.loading * 60_000, })); export default function NowLine() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); return ( {VEHICLES.map((vehicle) => ( ))} ); } ``` ##### Follow the clock `now` on its own reads the clock and moves on by the minute, so a plan left open on a wall screen stays true. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const HOUR = 60 * 60_000; const today = new Date(); today.setHours(0, 0, 0, 0); const at = (hours: number) => today.getTime() + hours * HOUR; const current = Date.now(); const TASKS: Task[] = [ { id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" }, { id: "secondary", name: "Secondary", color: "light-dark(#0d9488, #3cc7b8)" }, ]; /* Today's on-call hand-over at 09:00. */ const DUTIES: Subtask[] = [ { id: "p-1", task: "primary", lane: "ada", from: at(-15), to: at(9) }, { id: "p-2", task: "primary", lane: "tomasz", from: at(9), to: at(33) }, { id: "s-1", task: "secondary", lane: "tomasz", from: at(-15), to: at(9) }, { id: "s-2", task: "secondary", lane: "leila", from: at(9), to: at(33) }, ]; export default function FollowTheClock() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [current - 6 * HOUR, current + 6 * HOUR] } }); return ( ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`now`](#/schedule/ScheduleProps-now) | `boolean \| number` | `false` | A line marking the present across the lanes: `true` follows the clock by the minute, an instant fixes it there. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Pan and zoom Moving through a plan with the gestures of any scrolling surface: drag, wheel, Ctrl with the wheel, and a pinch. Only the view moves; the plan stays as it is. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/pan-and-zoom/ Dragging the background pans through time and through the lanes. The wheel scrolls the lanes and hands the scroll back to the page at their end; Shift with the wheel pans through time; Ctrl or ⌘ with the wheel, or a pinch, zooms around the pointer. No intent is raised: pan and zoom change the visible span and nothing else ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)). The span is part of the schedule's view, which `onViewChange` reports at most once per frame - see [View](#/view) and [Linked schedules](#/linked-schedules). #### Examples ##### Move through the plan Drag, wheel, Ctrl with the wheel and a pinch need nothing switched on; `zoomLimits` only bounds the span, here two hours to two days. ```tsx import { useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; /* Without `zoomLimits` the span may run from an hour to twenty-eight days. */ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const min = (n: number) => n * 60_000; const HOUR = 60 * 60_000; const TASKS: Task[] = [ { id: "release", name: "Release 4.12", color: "light-dark(#2563eb, #6b9bff)" }, { id: "inc-1048", name: "INC-1048 follow-up", color: "light-dark(#c2410c, #f08a52)" }, ]; const ENGINEERS = [ { id: "priya", label: "Priya Raman" }, { id: "jonas", label: "Jonas Keller" }, { id: "ada", label: "Ada Mwangi" }, ]; const WORK: Subtask[] = [ { id: "build", task: "release", lane: "priya", from: at(6), to: at(7), leadIn: min(15), leadOut: min(10) }, { id: "rollout", task: "release", lane: "jonas", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { id: "timeline", task: "inc-1048", lane: "ada", from: at(6, 30), to: at(8), leadIn: min(30), leadOut: min(15) }, { id: "postmortem", task: "inc-1048", lane: "jonas", from: at(12), to: at(13, 30), leadIn: min(15) }, ]; export default function PanAndZoom() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); return ( {ENGINEERS.map((engineer) => ( ))} ); } ``` #### Keyboard | Key | Action | |---|---| | `Home / End` | The first or last subtask on the lane; the view pans to bring it in. | | `PageUp / PageDown` | Along the lane by about a tenth of the visible span, the view following. | The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`zoomLimits`](#/schedule/ScheduleProps-zoomLimits) | [`ZoomLimits`](#/schedule/type-ZoomLimits) | one hour to 28 days | The narrowest and widest time span zoom may reach, in milliseconds. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ## Reading What a planner takes out of the picture: what is selected, what the pointer is on, and what a second view makes of it. ### Selection A click on a bar selects its whole task on every lane, so a planner sees every part of one job at once (also called highlighting). Reach for it when a list or a detail panel beside the plan should follow what was picked. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/selection/ The schedule keeps the selection itself unless you pass `selectedTask`; then your state holds it and every change is reported. The report also names the subtask that was clicked, the one the editing grips belong to. Selection and hover are two different marks: the selected task is outlined, the one under the pointer washed, and both show at once. The selection also steps the rest back, so a planner sees at once what they are working with: the clicked subtask keeps its task's full colour, the other subtasks of its task go halfway to grey, and all other work goes grey. Findings keep their colours, and a task selected by its dependency stays whole. #### Examples ##### Select a task Click a leg and the whole consignment is outlined on every vehicle: the leg you clicked in full colour, its other legs halfway to grey, all other work grey. Click the empty plot to clear it. ```tsx import { 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 CONSIGNMENTS: readonly Task[] = [ { id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" }, { id: "c-2042", name: "C-2042 Marlow & Finch Books", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const LEGS: readonly Subtask[] = [ { id: "c-2041-1", task: "c-2041", lane: "truck-118", from: at(6), to: at(7, 30) }, { id: "c-2041-2", task: "c-2041", lane: "van-214", from: at(8), to: at(10, 30) }, { id: "c-2041-3", task: "c-2041", lane: "truck-118", from: at(11), to: at(12) }, { id: "c-2042-1", task: "c-2042", lane: "truck-118", from: at(8), to: at(9) }, { id: "c-2042-2", task: "c-2042", lane: "van-214", from: at(11), to: at(12, 30) }, ]; const TRANSFERS: readonly Dependency[] = [ { id: "t-2041", from: "c-2041-1", to: "c-2041-2", lag: 20 * 60_000 }, { id: "t-2041-b", from: "c-2041-2", to: "c-2041-3", lag: 20 * 60_000 }, { id: "t-2042", from: "c-2042-1", to: "c-2042-2", lag: 20 * 60_000 }, ]; export default function SelectATask() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(13, 30)] } }); return ( ); } ``` ##### Control the selection Hold `selectedTask` in your state to show the selection elsewhere; `onSelectedTaskChange` also names the leg that was clicked. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { 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 DAY: readonly [number, number] = [at(5, 30), at(18)]; const VEHICLES = [ { id: "truck-118", label: "Truck FP 118 R" }, { id: "van-214", label: "Van FP 214 K" }, { 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" }, ]; const CONSIGNMENTS: readonly Task[] = [ { id: "c-2042", name: "C-2042 Marlow & Finch Books", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "c-2043", name: "C-2043 Oakridge Pharmacy", color: "light-dark(#c2410c, #f08a52)" }, ]; /* Lead-in is loading, lead-out unloading. */ const LEGS: readonly Subtask[] = [ { id: "c-2042-1", task: "c-2042", lane: "truck-118", from: at(7, 30), to: at(8, 15), leadIn: min(10) }, { id: "c-2042-2", task: "c-2042", lane: "van-214", from: at(9), to: at(11), leadIn: min(20), leadOut: min(15) }, { 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-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, ]; const TRANSFERS: readonly Dependency[] = [ { id: "t-2042-1", from: "c-2042-1", to: "c-2042-2", lag: min(15) }, { id: "t-2043-1", from: "c-2043-1", to: "c-2043-2", lag: min(45) }, /* Ten minutes between the van at 11:30 and the e-van's loading at 11:40; the transfer takes twenty-five. Violated, on purpose. */ { id: "t-2043-2", from: "c-2043-2", to: "c-2043-3", lag: min(25) }, ]; export default function ControlTheSelection() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: DAY } }); const [selected, setSelected] = useState("c-2043"); const [leg, setLeg] = useState(null); const consignment = CONSIGNMENTS.find((c) => c.id === selected); return ( { setSelected(task); setLeg(subtask); }} > {VEHICLES.map((vehicle) => ( ))} Selected: {consignment?.name ?? "nothing"} {leg !== null ? `, at ${leg}` : ""} ); } ``` #### Keyboard | Key | Action | |---|---| | `Tab` | Focuses the plot; the first subtask in view becomes the active one. | | `← →` | The previous or next subtask on the same lane. | | `↑ ↓` | The subtask on the lane above or below that lies nearest in time. | | `Home End` | The first or last subtask on the lane. | | `Page Up Page Down` | A tenth of the view back or on along the lane. | | `] or T` | Out along the dependency that leaves the active subtask. | | `[ or Shift+T` | Back along the dependency that arrives at it. | | `Enter or Space` | Selects the active subtask's task, as a click does. | | `Escape` | Lets go of the active subtask. | The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`selectedTask`](#/schedule/ScheduleProps-selectedTask) | `string \| null` | — | The selected task, controlled. Leave it out and the schedule keeps the selection itself. | | [`onSelectedTaskChange`](#/schedule/ScheduleProps-onSelectedTaskChange) | `(task: string \| null, subtask: string \| null) => void` | — | Called when a click selects a task or clears the selection, with the subtask that was clicked - null where the click was on a dependency or on nothing. It is called again when another subtask of the same task is clicked. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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 - One task at a time: there is no multiple selection. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Interactions Reports what the pointer does on the plan - hover, click, right-click - with what it hit and the time and lane under it. Reach for it to open a menu, fill a detail panel or write a status line. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/interactions/ A hit names a subtask together with the part of it (lead-in, main time, lead-out), a dependency, a lane, or nothing. While `onInteraction` is set, a right-click opens no browser menu: your application is expected to answer it. #### Examples ##### Report what the pointer is on `onInteraction` hears hover, click and right-click with the target, its part, the time and the lane - here written into a status line. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Dependency, ScheduleInteraction, 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 CONSIGNMENTS: readonly Task[] = [ { id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" }, ]; 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-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, ]; const TRANSFERS: readonly Dependency[] = [{ id: "t-2041-1", from: "c-2041-1", to: "c-2041-2", lag: min(10) }]; const describe = (interaction: ScheduleInteraction): string => { const { hit } = interaction; /* To the nearest minute: a pixel is about a minute wide here. */ const time = new Date(Math.round(interaction.time / 60_000) * 60_000).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); const target = hit.kind === "subtask" ? `subtask ${hit.subtask.id} (${hit.part})` : hit.kind === "dependency" ? `dependency ${hit.dependency.id}` : hit.kind === "lane" ? "free time" : "nothing"; return `${interaction.type}: ${target} at ${time}, lane ${interaction.lane ?? "none"}`; }; export default function ReportThePointer() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); const [last, setLast] = useState("Move the pointer over the plan"); return ( setLast(describe(interaction))} > {last} ); } ``` ##### Open a context menu On a `contextmenu` interaction, open `ContextMenu` from @umriss-ui/core at `clientX` and `clientY`; its entries change your data. ```tsx import { useState } from "react"; import { ContextMenu, MenuItem } from "@umriss-ui/core"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the operations world, written out here so the example runs on its own. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); interface Engineer { id: string; name: string; team: string; } const ENGINEERS: readonly Engineer[] = [ { id: "priya", name: "Priya Raman", team: "Payments" }, { id: "jonas", name: "Jonas Keller", team: "Payments" }, { id: "ada", name: "Ada Mwangi", team: "Identity" }, { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" }, { id: "leila", name: "Leila Haddad", team: "Discovery" }, { id: "sam", name: "Sam Okafor", team: "Messaging" }, { id: "ines", name: "Ines Duarte", team: "Integrations" }, { id: "felix", name: "Felix Brandt", team: "Insights" }, ]; interface OnCall { id: string; engineer: string; rotation: "primary" | "secondary"; from: number; to: number; } /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the secondary of one day is the primary of the next. */ const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id }, { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id }, ].map(({ rotation, engineer }) => ({ id: `${rotation}-${16 + day}`, engineer, rotation, from: at(16 + day, 9), to: at(17 + day, 9), }))).flat(); const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime(); const ROTATIONS: readonly Task[] = [ { id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" }, { id: "secondary", name: "Secondary", color: "light-dark(#0d9488, #3cc7b8)" }, ]; /* The rotation is the task, the engineer the lane. */ const START: readonly Subtask[] = ONCALL.map((duty) => ({ id: duty.id, task: duty.rotation, lane: duty.engineer, from: duty.from, to: duty.to, name: duty.rotation === "primary" ? "Primary" : "Secondary", })); export default function OpenAContextMenu() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 6), day(23, 12)] } }); const [rota, setRota] = useState(START); const [menu, setMenu] = useState<{ interaction: ScheduleInteraction; duty: Subtask } | null>(null); /* While `onInteraction` is set, a right-click opens no browser menu. */ const onInteraction = (interaction: ScheduleInteraction) => { if (interaction.type !== "contextmenu") return; setMenu(interaction.hit.kind === "subtask" ? { interaction, duty: interaction.hit.subtask } : null); }; return ( <> {ENGINEERS.map((engineer) => ( ))} { if (!open) setMenu(null); }} ariaLabel={menu !== null ? `Hand over ${menu.duty.id}` : "Hand over"} > {menu !== null && ENGINEERS.filter((engineer) => engineer.id !== menu.duty.lane).map((engineer) => ( { const intent = { kind: "lane", subtask: menu.duty.id, lane: engineer.id } as const; setRota((current) => current.map((duty) => applyIntent(duty, intent))); }} > Hand to {engineer.name} ))} ); } ``` #### When to use something else - You only need to know which task was picked → Selection - You need the time at a point outside an interaction → The handle #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `ScheduleInteraction` | Prop | Type | Default | Description | |---|---|---|---| | `type` *required* | `"click" \| "contextmenu" \| "hover"` | — | `"hover"` is reported when the target under the pointer changes, not on every movement.
Shown in: [Report what the pointer is on](#/interactions/report-the-pointer), [Open a context menu](#/interactions/open-a-context-menu), [Show a detail panel instead (Tooltip)](#/tooltip/detail-panel-instead) and 1 more | | `hit` *required* | [`ScheduleHit`](#type-ScheduleHit) | — | What the pointer is on.
Shown in: [Report what the pointer is on](#/interactions/report-the-pointer), [Open a context menu](#/interactions/open-a-context-menu), [Show a detail panel instead (Tooltip)](#/tooltip/detail-panel-instead) and 2 more | | `clientX` *required* | `number` | — | The pointer's horizontal client coordinate - where a `ContextMenu` opens.
Shown in: [Open a context menu](#/interactions/open-a-context-menu), [Place a mark of your own (The handle)](#/handle/place-a-mark), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `clientY` *required* | `number` | — | The pointer's vertical client coordinate.
Shown in: [Open a context menu](#/interactions/open-a-context-menu), [Place a mark of your own (The handle)](#/handle/place-a-mark), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `time` *required* | `number` | — | The wall-clock instant under the pointer.
Shown in: [Report what the pointer is on](#/interactions/report-the-pointer) | | `lane` *required* | `string \| null` | — | The lane under the pointer, or null below the last one.
Shown in: [Report what the pointer is on](#/interactions/report-the-pointer) | ##### Types on this page ###### `ScheduleHit` What a pointer is on. ```ts type ScheduleHit = | { readonly kind: "subtask"; readonly subtask: Subtask; readonly part: "leadIn" | "main" | "leadOut" } | { readonly kind: "dependency"; readonly dependency: Dependency } | { readonly kind: "milestone"; readonly milestone: Milestone } | { readonly kind: "lane"; readonly lane: string } | { readonly kind: "nothing" }; ``` ### Tooltip Names what the pointer rests on - a subtask with its times and findings, a dependency with its lag - without a click (also called a hover card). Replace its content when your application knows more than the schedule. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/tooltip/ Its words come from the wording of @umriss-ui/core and its numbers from its formats, so a provider switches it with everything else. It steps aside while a drag is in flight and is placed where it has room. The keyboard's active subtask shows the same tooltip as the pointer. #### Examples ##### Read the default tooltip Rest the pointer on a leg: the tooltip names the consignment, its times, loading and unloading, and every finding on it. ```tsx import { 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 VEHICLES = [ { 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" }, ]; 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)" }, ]; /* Two legs share the van from 10:00 - an overlap - and the transfer onto the e-van at 11:30 is fifteen minutes short. */ const LEGS: readonly Subtask[] = [ { id: "c-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { 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-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, ]; 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 DefaultTooltip() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); return ( {VEHICLES.map((vehicle) => ( ))} ); } ``` ##### Write your own tooltip content Pass `tooltip` a function to show what only your application knows - here the load - with the findings the schedule found. ```tsx import { Badge, Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Dependency, ScheduleTooltipTarget, 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: "van-402", label: "Van FP 402 R" }, { id: "truck-520", label: "Truck FP 520 E" }, { id: "van-455", label: "E-van FP 455 R" }, ]; 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)" }, ]; const LEGS: readonly Subtask[] = [ { id: "c-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { 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-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, ]; 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) }, ]; /* What the application knows and the schedule does not. */ const LOADS: Record = { "c-2041": "6 pallets · 1,140 kg", "c-2043": "2 pallets · 420 kg, chilled", }; function LoadTooltip({ target }: { target: ScheduleTooltipTarget }) { const task = target.kind === "milestone" ? undefined : target.task; const found = target.kind === "subtask" ? target.overlapping.length + target.violatedDependencies.length : target.kind === "dependency" && target.violated ? 1 : 0; return ( {task?.name ?? "Unknown consignment"} {task ? LOADS[task.id] : ""} {found > 0 && {found === 1 ? "1 finding" : `${found} findings`}} ); } export default function OwnTooltip() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); return ( } > {VEHICLES.map((vehicle) => ( ))} ); } ``` ##### Show a detail panel instead Set `tooltip={false}` when a panel of your own shows the item under the pointer, filled from `onInteraction`. ```tsx import { useState } from "react"; import { Badge, Card, CardBody, Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { ScheduleInteraction } 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"); const WEB_WORK = WORK.filter((item) => WEB.some((person) => person.id === item.lane)); export default function DetailPanelInstead() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(9), day(21)] } }); const [item, setItem] = useState(null); const onInteraction = (interaction: ScheduleInteraction) => { if (interaction.type !== "hover") return; const hit = interaction.hit; setItem(hit.kind === "subtask" ? (WEB_WORK.find((one) => one.id === hit.subtask.id) ?? null) : null); }; return ( {WEB.map((person) => ( ))} {item === null ? ( Rest the pointer on a work item to see its details. ) : ( {item.name} {item.status} {PROJECTS.find((project) => project.id === item.task)?.name} · {PEOPLE.find((person) => person.id === item.lane)?.name} ·{" "} {item.estimate} h estimated )} ); } ``` ##### Say what a bar covers and a line joins The target says what is hovered: a bar brings its `subtask` and the `blocked` time it covers, a line its `dependency` with the subtasks it joins, `from` and `to`. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime, Dependency, ScheduleTooltipTarget, 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 PROJECTS: Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#2563eb, #6b9bff)" }]; const WORK: Subtask[] = [ { id: "design", task: "portal", lane: "noah", from: at(7), to: at(9), name: "Design" }, { id: "build", task: "portal", lane: "arjun", from: at(9, 30), to: at(13), name: "Build" }, { id: "test", task: "portal", lane: "eva", from: at(13, 30), to: at(15), name: "Test" }, ]; const HANDOVERS: Dependency[] = [ { id: "to-build", from: "design", to: "build", lag: min(15) }, { id: "to-test", from: "build", to: "test", lag: min(15) }, ]; const LEAVE: BlockedTime[] = [{ id: "arjun-dentist", lane: "arjun", from: at(11), to: at(12), label: "Dentist" }]; const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); function Covers({ target }: { target: ScheduleTooltipTarget }) { if (target.kind === "dependency") { return ( Handover {target.dependency.id} {target.from?.name ?? "?"} → {target.to?.name ?? "?"} ); } if (target.kind === "milestone") return null; return ( {target.subtask.name} {time(target.subtask.from)} – {time(target.subtask.to)} {target.blocked.map((blocked) => ( Covers {blocked.label}, {time(blocked.from)} – {time(blocked.to)} ))} ); } export default function SayWhatABarCoversAndALineJoins() { const { Schedule, Lane, Subtasks, Dependencies, BlockedTimes } = useSchedule({ initialView: { domain: [at(6, 30), at(15, 30)] } }); return ( }> ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `ScheduleTooltipTarget` | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"subtask" \| "milestone" \| "dependency"` | — | A subtask is hovered. A milestone is hovered. A dependency is hovered.
Shown in: [Write your own tooltip content](#/tooltip/own-tooltip), [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins), [Say what a milestone is in your own tooltip (Milestones)](#/milestones/say-what-a-milestone-is) | | `subtask` | [`Subtask`](#/subtasks/type-Subtask) | — | The hovered subtask.
Shown in: [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins), [Say what a milestone is in your own tooltip (Milestones)](#/milestones/say-what-a-milestone-is) | | `task` | [`Task`](#/subtasks/type-Task)` \| undefined` | — | Its task, where the tasks name it.
Shown in: [Write your own tooltip content](#/tooltip/own-tooltip) | | `overlapping` | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks it overlaps with on its lane.
Shown in: [Write your own tooltip content](#/tooltip/own-tooltip) | | `violatedDependencies` | `readonly `[`ViolatedDependency`](#/findings/type-ViolatedDependency)`[]` | — | The violated dependencies leaving or reaching it.
Shown in: [Write your own tooltip content](#/tooltip/own-tooltip) | | `blocked` | `readonly `[`BlockedTime`](#/blocked-time/type-BlockedTime)`[]` | — | The blocked time it covers on its lane.
Shown in: [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins) | | `milestone` | [`Milestone`](#/milestones/type-Milestone) | — | The hovered milestone.
Shown in: [Say what a milestone is in your own tooltip (Milestones)](#/milestones/say-what-a-milestone-is) | | `dependency` | [`Dependency`](#/dependencies/type-Dependency) | — | The hovered dependency.
Shown in: [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins) | | `from` | [`Subtask`](#/subtasks/type-Subtask)` \| undefined` | — | The subtask it leaves.
Shown in: [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins) | | `to` | [`Subtask`](#/subtasks/type-Subtask)` \| undefined` | — | The subtask it reaches.
Shown in: [Say what a bar covers and a line joins](#/tooltip/say-what-a-bar-covers-and-a-line-joins) | | `violated` | [`ViolatedDependency`](#/findings/type-ViolatedDependency)` \| undefined` | — | Its finding, where it is violated.
Shown in: [Write your own tooltip content](#/tooltip/own-tooltip) | ### View How the planner is looking at the plan - the span in view and the folded lane groups - as one value to keep and hand back. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/view/ The schedule keeps its view itself and puts it nowhere, not in the address and not in any storage. `view` on what [`useSchedule`](#/api/useSchedule) hands back holds it, and `onViewChange` reports every change the planner makes to it - a pan or zoom at most once per frame; keep it where the application keeps things and go back to it with `setView`. `initialView` is the start, read once: a different value on a later render is ignored. `setView(view)` goes to a whole view at once, and what it leaves out goes back to its default; the same view again changes nothing, and `onViewChange` does not hear it. Without a span the schedule shows the extent of its subtasks, within `zoomLimits`. A group no `LaneGroup` declares falls out. `setDomain`, `toggleGroup`, `foldAll` and `unfoldAll` change the view from outside - a button of the application's own that shows the whole plan or folds every team. The selection is not part of the view (`selectedTask`). #### Examples ##### Keep and restore a view `onViewChange` reports the span and the folded groups as one view; keep it - here in the browser's storage, so it outlives a reload - and go back to it with `setView`. Pan, zoom, fold, keep, reload, restore. ```tsx import { useState } from "react"; import { Button, Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { ScheduleView, 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 START: ScheduleView = { domain: [at(6), at(16)] }; const KEPT = "umriss-schedule-demo-view"; /* Storage can be missing or refuse - a private window, a blocked site. The view is then simply not kept. */ function readKept(): ScheduleView | null { try { const stored = localStorage.getItem(KEPT); return stored === null ? null : (JSON.parse(stored) as ScheduleView); } catch { return null; } } function keep(view: ScheduleView): void { try { localStorage.setItem(KEPT, JSON.stringify(view)); } catch { /* Not kept; the schedule works on regardless. */ } } const TEAMS: Task[] = [ { id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }, { id: "billing", name: "Billing run", color: "light-dark(#0d9488, #3cc7b8)" }, ]; const WORK: Subtask[] = [ { id: "p1", task: "portal", lane: "noah", from: at(7), to: at(9, 30), leadOut: min(15) }, { id: "p2", task: "portal", lane: "arjun", from: at(10), to: at(13), leadIn: min(30) }, { id: "b1", task: "billing", lane: "mei", from: at(8), to: at(11) }, { id: "b2", task: "billing", lane: "lukas", from: at(11, 30), to: at(14, 30), leadIn: min(15) }, ]; export default function KeepAndRestore() { /* The schedule holds its view; `view` is what it shows now, and `setView` restores a kept one. */ const [kept, setKept] = useState(readKept); const { Schedule, Lane, LaneGroup, Subtasks, view, setView, setDomain } = useSchedule({ initialView: START }); return ( What the application would keep {/* JSON has no space to break at: without this it ran past a phone's card. */} {JSON.stringify(view)} ); } ``` #### When to use something else - Two schedules on the same hours → Linked schedules #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `ScheduleOptions` | Prop | Type | Default | Description | |---|---|---|---| | `initialView` | [`ScheduleView`](#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.
Shown in: [Keep and restore a view](#/view/keep-and-restore), [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule) and 78 more | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onViewChange` | `(view: `[`ScheduleView`](#type-ScheduleView)`) => void` | — | Every change the planner makes, once, always the whole view - the one to keep, or to hand another schedule's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the schedule starts with, nor for one `setView` applies.
Shown in: [Keep two schedules in step (Linked schedules)](#/linked-schedules/in-step), [Read a rota beside the incidents (Linked schedules)](#/linked-schedules/rota-beside-incidents) | ##### `ScheduleView` | Prop | Type | Default | Description | |---|---|---|---| | `domain` | `readonly [number, number]` | — | The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within `zoomLimits`.
Shown in: [Keep and restore a view](#/view/keep-and-restore), [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule) and 77 more | | `folded` | `readonly string[]` | — | The **Lane group**s that are folded, by id. An id no group carries falls out. A folded outer group hides the inner ones without their ids leaving the list, so unfolding it gives back the view that was there.
Shown in: [Fold groups from outside (Lane groups)](#/lane-groups/fold-from-outside), [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Start with groups folded (Lane groups)](#/lane-groups/start-with-groups-folded) | ### Linked schedules Keeps two schedules on the same hours: pan or zoom one and the other follows (also called synchronised views). Reach for it when two sets of lanes stand apart on a screen but belong to the same time. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/linked-schedules/ Each schedule has its own [`useSchedule`](#/api/useSchedule). Send what one reports to the other's `setView`, in both directions: the other goes there at once, without a remount, and `setView` is not reported, so the two never answer each other. While a drag is in flight the two can stand a frame apart; they settle as soon as it ends. #### Examples ##### Keep two schedules in step Send what `onViewChange` reports to the other schedule's `setView`: pan or zoom either one and the other follows - no `key`, no remount. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { ScheduleParts, 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 DIESEL = [ { id: "truck-118", label: "Truck FP 118 R" }, { id: "van-214", label: "Van FP 214 K" }, { id: "van-402", label: "Van FP 402 R" }, { id: "truck-520", label: "Truck FP 520 E" }, ]; const ELECTRIC = [ { id: "van-455", label: "E-van FP 455 R" }, { id: "van-377", label: "E-van FP 377 K" }, ]; const CONSIGNMENTS: readonly Task[] = [ { id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" }, { id: "c-2042", name: "C-2042 Marlow & Finch Books", color: "light-dark(#0d9488, #3cc7b8)" }, { id: "c-2043", name: "C-2043 Oakridge Pharmacy", color: "light-dark(#c2410c, #f08a52)" }, ]; 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-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { id: "c-2041-3", task: "c-2041", lane: "van-377", from: at(11, 30), to: at(12, 15) }, { id: "c-2042-1", task: "c-2042", lane: "truck-118", from: at(7, 30), to: at(8, 15), leadIn: min(10) }, { id: "c-2042-2", task: "c-2042", lane: "van-214", from: at(9), to: at(11), leadIn: min(20), leadOut: min(15) }, { 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-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, ]; const on = (lanes: readonly { id: string }[]) => LEGS.filter((leg) => lanes.some((lane) => lane.id === leg.lane)); const DIESEL_LEGS = on(DIESEL); const ELECTRIC_LEGS = on(ELECTRIC); const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); export default function InStep() { /* Both start on the same span; each reports what the reader does to it and the other goes there by `setView`, which is not reported in turn. */ const start = { domain: [at(5, 30), at(18)] } as const; const diesel: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => electric.setView(v) }); const electric: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => diesel.setView(v) }); const span = diesel.view.domain ?? start.domain; return ( {DIESEL.map((vehicle) => ( ))} {ELECTRIC.map((vehicle) => ( ))} {time(span[0])} – {time(span[1])} ); } ``` ##### Read a rota beside the incidents Two schedules with different lanes over one span: who was on call, above the incidents they were paged for. ```tsx import { Stack } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { ScheduleParts, Subtask, Task } from "@umriss-ui/schedule"; /* Data from the operations world, written out here so the example runs on its own. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */ const NOW = at(17, 10, 30); interface Service { id: string; name: string; team: string; /** 1 is customer-facing and pages at night; 3 waits for the morning. */ tier: 1 | 2 | 3; /** The latency objective: the 95th percentile stays below this, in ms. */ latencySlo: number; /** The availability promised for a month, in per cent. */ availabilityTarget: number; } const SERVICES: readonly Service[] = [ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 }, { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 }, { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 }, { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 }, { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 }, { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 }, { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 }, { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 }, ]; interface Engineer { id: string; name: string; team: string; } const ENGINEERS: readonly Engineer[] = [ { id: "priya", name: "Priya Raman", team: "Payments" }, { id: "jonas", name: "Jonas Keller", team: "Payments" }, { id: "ada", name: "Ada Mwangi", team: "Identity" }, { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" }, { id: "leila", name: "Leila Haddad", team: "Discovery" }, { id: "sam", name: "Sam Okafor", team: "Messaging" }, { id: "ines", name: "Ines Duarte", team: "Integrations" }, { id: "felix", name: "Felix Brandt", team: "Insights" }, ]; interface OnCall { id: string; engineer: string; rotation: "primary" | "secondary"; from: number; to: number; } /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the secondary of one day is the primary of the next. */ const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id }, { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id }, ].map(({ rotation, engineer }) => ({ id: `${rotation}-${16 + day}`, engineer, rotation, from: at(16 + day, 9), to: at(17 + day, 9), }))).flat(); interface Incident { id: string; title: string; service: string; severity: "SEV1" | "SEV2" | "SEV3"; opened: number; acknowledged?: number; resolved?: number; /** An engineer's id. */ assignee: string; } const INCIDENTS: readonly Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" }, { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" }, { id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" }, { id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" }, { id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" }, { id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" }, { id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" }, ]; const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime(); const COLORS = [ "light-dark(#2563eb, #6b9bff)", "light-dark(#0d9488, #3cc7b8)", "light-dark(#7c3aed, #a98bfa)", "light-dark(#c2410c, #f08a52)", "light-dark(#be185d, #f06aa6)", "light-dark(#4d7c0f, #8fc43e)", "light-dark(#0369a1, #5cb8f0)", "light-dark(#a16207, #e0b44a)", ]; /* Above, the engineer is the task and the rotation the lane. */ const PEOPLE: readonly Task[] = ENGINEERS.map((engineer, i) => ({ id: engineer.id, name: engineer.name, color: COLORS[i]! })); const DUTIES: readonly Subtask[] = ONCALL.map((duty) => ({ id: duty.id, task: duty.engineer, lane: duty.rotation, from: duty.from, to: duty.to })); /* Below, the severity is the task and the service the lane; an open incident runs to now. */ const SEVERITIES: readonly Task[] = [ { id: "SEV1", name: "SEV1", color: "light-dark(#b91c1c, #f87171)" }, { id: "SEV2", name: "SEV2", color: "light-dark(#c2410c, #f08a52)" }, { id: "SEV3", name: "SEV3", color: "light-dark(#57534e, #a8a29e)" }, ]; const PAGED: readonly Subtask[] = INCIDENTS.map((incident) => ({ id: incident.id, task: incident.severity, lane: incident.service, from: incident.opened, to: incident.resolved ?? NOW, name: `${incident.id} ${incident.title}`, })); const HIT = SERVICES.filter((service) => PAGED.some((incident) => incident.lane === service.id)); export default function RotaBesideIncidents() { const start = { domain: [day(16, 6), day(17, 14)] } as const; const rota: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => incidents.setView(v) }); const incidents: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => rota.setView(v) }); return ( {HIT.map((service) => ( ))} ); } ``` #### When to use something else - The lanes belong together in one plan → Lane groups #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onViewChange`](#/view/ScheduleOptions-onViewChange) | `(view: `[`ScheduleView`](#/view/type-ScheduleView)`) => void` | — | Every change the planner makes, once, always the whole view - the one to keep, or to hand another schedule's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the schedule starts with, nor for one `setView` applies. | From `ScheduleView` — the full table stands on [View](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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 - Only the view is shared - the span and the folded groups, not the selection, the hover or the vertical scroll. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### The handle Converts between a point on the screen and a time on a lane, so marks of your own - a cut-off, a delivery window, a pin - stand at the right place beside the plan. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/handle/ The handle holds three functions and nothing else: `clientPointOf(time, lane?)`, `positionAt(clientX, clientY)` and `visibleDomain()`. Everything else the schedule does is props. Place your marks again whenever `view.domain` changes. #### Examples ##### Place a mark of your own `clientPointOf` turns a time into a point for your own mark; `positionAt` turns a pointer back into a time and a lane. ```tsx import { useEffect, useRef, useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { ScheduleHandle, 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" }, ]; 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)" }, ]; 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-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { 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(12), to: at(13, 30), leadIn: min(15) }, ]; /** The last moment a parcel is taken for delivery today. */ const CUT_OFF = at(14); const clock = (time: number) => new Date(time).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); export default function PlaceAMark() { const { Schedule, Lane, Subtasks, view } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); const plan = useRef(null); const host = useRef(null); const [pin, setPin] = useState(null); const [under, setUnder] = useState("Move the pointer over the plan"); /* The client point, turned into one inside this example - anew whenever the span moves. */ useEffect(() => { const point = plan.current?.clientPointOf(CUT_OFF); const left = host.current?.getBoundingClientRect().left; setPin(point == null || left === undefined ? null : point.x - left); }, [view.domain]); return (
{pin !== null && ( Same-day cut-off )}
{ /* The interaction carries time and lane already; `positionAt` is for listeners of your own, which have only a point. */ const there = plan.current?.positionAt(interaction.clientX, interaction.clientY); setUnder(there == null ? "off the plan" : `${clock(there.time)} on ${there.lane ?? "no lane"}`); }} > {VEHICLES.map((vehicle) => ( ))} {under}
); } ``` #### When to use something else - You need the time under the pointer during a hover or a click → Interactions #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`ref`](#/schedule/ScheduleProps-ref) | `Ref<`[`ScheduleHandle`](#/schedule/type-ScheduleHandle)`>` | — | Receives the `ScheduleHandle` - not an element. | | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onInteraction`](#/schedule/ScheduleProps-onInteraction) | `(interaction: `[`ScheduleInteraction`](#/interactions/type-ScheduleInteraction)`) => void` | — | Click, context menu and hover, with what the pointer is on and where. While it is set, a right-click opens no browser menu. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleInteraction` — the full table stands on [Interactions](#/interactions/type-ScheduleInteraction). | Prop | Type | Default | Description | |---|---|---|---| | [`clientX`](#/interactions/ScheduleInteraction-clientX) *required* | `number` | — | The pointer's horizontal client coordinate - where a `ContextMenu` opens. | | [`clientY`](#/interactions/ScheduleInteraction-clientY) *required* | `number` | — | The pointer's vertical client coordinate. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Findings as data Lists what is wrong with a plan - double bookings on a lane, dependencies that do not fit, work in blocked time - from the same data the schedule draws. Reach for it to list, count or filter the problems beside the picture. ```ts import { findings, overlaps, violatedDependencies, inBlockedTime } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/findings/ `findings(subtasks, dependencies, blocked)` returns all three kinds; [`overlaps`](#/api/overlaps), [`violatedDependencies`](#/api/violatedDependencies) and [`inBlockedTime`](#/api/inBlockedTime) return one each. They are pure functions: run them wherever the data changes, with or without a schedule on screen. Lead-in and lead-out count as occupied time; touching at a shared edge is no overlap. #### Examples ##### List the findings [`findings`](#/api/findings) returns what the schedule marks - overlaps, violated dependencies, work in blocked time - as data for a list beside it. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { findings, useSchedule } from "@umriss-ui/schedule"; import type { BlockedTime, Dependency, 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)" }, { id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }, ]; const WORK: 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(17), to: day(19, 17), name: "Search results page" }, { id: "address", task: "portal", lane: "chloe", from: day(18), to: day(19, 17), name: "Change of address form" }, { id: "profile", task: "portal", lane: "arjun", from: day(17), to: day(19, 17), name: "Profile page" }, ]; /* The build may start only a morning after the design is done. */ const HANDOVERS: readonly Dependency[] = [{ id: "design-to-build", from: "design", to: "build", lag: 4 * 60 * 60_000 }]; const LEAVE: readonly BlockedTime[] = [{ id: "arjun-holiday", lane: "arjun", from: day(18, 0), to: day(21, 0), label: "Holiday" }]; const when = (instant: number) => new Date(instant).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); const hours = (ms: number) => Math.round(ms / 3_600_000); export default function ListTheFindings() { const { Schedule, Lane, Subtasks, Dependencies, BlockedTimes } = useSchedule({ initialView: { domain: [day(16, 0), day(21, 0)] } }); const found = findings(WORK, HANDOVERS, LEAVE); return (
    {found.overlaps.map((o) => ( Double-booked: {o.lane} has {o.first} and {o.second} from {when(o.from)} to {when(o.to)} ))} {found.violatedDependencies.map((v) => ( Too early: {v.dependency} leaves {when(v.departure)} for a start at {when(v.arrival)}, {hours(v.shortBy)} hours short ))} {found.inBlockedTime.map((b) => ( In leave: {b.subtask} covers {b.blocked} from {when(b.from)} to {when(b.to)} ))}
); } ``` ##### Say when there is nothing to fix When all three lists [`findings`](#/api/findings) returns are empty, say so plainly instead of showing an empty list. ```tsx import { EmptyState, Stack } from "@umriss-ui/core"; import { findings, 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: "booking", name: "Booking app", color: "light-dark(#0d9488, #3cc7b8)" }]; const WORK: readonly Subtask[] = [ { id: "w-111", task: "booking", lane: "hana", from: day(16), to: day(18, 17), name: "Push notifications" }, { id: "w-113", task: "booking", lane: "kofi", from: day(16), to: day(18, 17), name: "Calendar sync" }, ]; export default function NothingToFix() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 0), day(21, 0)] } }); const found = findings(WORK, []); const count = found.overlaps.length + found.violatedDependencies.length + found.inBlockedTime.length; return ( {count === 0 && } ); } ``` ##### Count the findings per kind [`overlaps`](#/api/overlaps) and [`violatedDependencies`](#/api/violatedDependencies) are the halves of [`findings`](#/api/findings) on their own - here as counts above the day's legs. ```tsx import { Badge, Stack } from "@umriss-ui/core"; import { overlaps, useSchedule, violatedDependencies } 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 VEHICLES = [ { 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" }, ]; 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)" }, ]; const LEGS: readonly Subtask[] = [ { id: "c-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) }, { 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-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) }, ]; 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 CountPerKind() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); const doubleBooked = overlaps(LEGS).length; const late = violatedDependencies(LEGS, TRANSFERS).length; return ( 0 ? "warning" : "success"}>{doubleBooked} double-booked 0 ? "warning" : "success"}>{late} transfers too short {VEHICLES.map((vehicle) => ( ))} ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### API ##### `Overlap` | Prop | Type | Default | Description | |---|---|---|---| | `lane` *required* | `string` | — | The lane both sit on.
Shown in: [List the findings](#/findings/list-the-findings), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) | | `first` *required* | `string` | — | The subtask that starts earlier (lead-in included); on a tie, the one that stands first in the data.
Shown in: [List the findings](#/findings/list-the-findings), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) | | `second` *required* | `string` | — | The other one.
Shown in: [List the findings](#/findings/list-the-findings), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) | | `from` *required* | `number` | — | Where the covering begins.
Shown in: [List the findings](#/findings/list-the-findings) | | `to` *required* | `number` | — | Where it ends.
Shown in: [List the findings](#/findings/list-the-findings) | ##### `ViolatedDependency` | Prop | Type | Default | Description | |---|---|---|---| | `dependency` *required* | `string` | — | The id of the dependency.
Shown in: [List the findings](#/findings/list-the-findings) | | `departure` *required* | `number` | — | When it leaves, by its anchor.
Shown in: [List the findings](#/findings/list-the-findings) | | `arrival` *required* | `number` | — | When it has to have arrived, by its anchor.
Shown in: [List the findings](#/findings/list-the-findings) | | `shortBy` *required* | `number` | — | How much time is missing: departure plus lag, minus arrival.
Shown in: [List the findings](#/findings/list-the-findings) | ##### `InBlockedTime` | Prop | Type | Default | Description | |---|---|---|---| | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [List the findings](#/findings/list-the-findings), [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Check a sprint against the leave calendar (Blocked time)](#/blocked-time/team-leave) and 2 more | | `blocked` *required* | `string` | — | The id of the blocked time it covers.
Shown in: [List the findings](#/findings/list-the-findings), [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Plan the sprint around leave (Scenarios)](#/scenarios/plan-the-sprint-around-leave) | | `lane` *required* | `string` | — | The lane both are on.
Shown in: [Hand over the on-call week (Scenarios)](#/scenarios/hand-over-the-on-call-week), [Plan the sprint around leave (Scenarios)](#/scenarios/plan-the-sprint-around-leave) | | `from` *required* | `number` | — | Where the covering begins.
Shown in: [List the findings](#/findings/list-the-findings), [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Hand over the on-call week (Scenarios)](#/scenarios/hand-over-the-on-call-week) | | `to` *required* | `number` | — | Where it ends.
Shown in: [List the findings](#/findings/list-the-findings), [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Hand over the on-call week (Scenarios)](#/scenarios/hand-over-the-on-call-week) | #### Known limits - Overlapping work is marked, not packed into sub-lanes; stacked overlap is not built yet. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## Editing Direct manipulation that changes nothing by itself: the schedule reports, the application decides. ### Move and lane 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`](#/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`](#/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 ( setWork((current) => current.map((item) => applyIntent(item, intent)))} > ); } ``` ##### 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(null); return ( { setLast(intent); setLegs((current) => current.map((leg) => applyIntent(leg, intent))); }} > {VEHICLES.map((vehicle) => ( ))} {last === null ? "No intent yet" : JSON.stringify(last)} ); } ``` ##### Refuse a move that double-books someone Your `onIntent` decides: here it applies a drop only when [`overlaps`](#/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(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 ( {WEB.map((person) => ( ))} {said} ); } ``` #### 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](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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](#/findings/type-Overlap). | Prop | Type | Default | Description | |---|---|---|---| | [`lane`](#/findings/Overlap-lane) *required* | `string` | — | The lane both sit on. | | [`first`](#/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`](#/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/). ### Stretch, lead-in, lead-out Change how long work takes, or how long its preparation and its wrap-up take, by dragging an edge (also called resizing). Each part changes on its own and is reported for your application to apply. ```ts import { useSchedule, applyIntent } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/stretch/ `stretch` enables the two edges of the main time. `leadIn` and `leadOut` add a grip at the outer edge of each; the grips show on the selected subtask only, so click a bar first. Stretching the main time leaves the lead-in and the lead-out as long as they were. #### Examples ##### Stretch the main time With `stretch` in `intents`, either edge of a bar drags to a new start or end; the other edge stays put. ```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: "booking", name: "Booking app", color: "light-dark(#0d9488, #3cc7b8)" }]; const START: readonly Subtask[] = [ { id: "w-111", task: "booking", lane: "hana", from: day(16), to: day(18, 17), name: "Push notifications" }, { id: "w-116", task: "booking", lane: "david", from: day(16), to: day(19, 13), name: "Device test matrix" }, ]; export default function StretchTheMainTime() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 0), day(21, 0)] } }); const [work, setWork] = useState(START); return ( setWork((current) => current.map((item) => applyIntent(item, intent)))} > ); } ``` ##### Change lead-in and lead-out Add `leadIn` and `leadOut`: a selected tour shows a grip for its loading and its unloading, each changed on its own. ```tsx import { useState } from "react"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { 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: Task[] = [{ id: "north", name: "North depot tours", color: "light-dark(#0d9488, #3cc7b8)" }]; /* Lead-in is loading at the depot, lead-out unloading the returns. */ const START: Subtask[] = [ { id: "T-01", task: "north", lane: "van-214", from: at(8), to: at(10), leadIn: min(30), leadOut: min(15) }, { id: "T-02", task: "north", lane: "van-377", from: at(11), to: at(12, 30), leadIn: min(15) }, ]; export default function ChangeLeadInAndLeadOut() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(13, 30)] } }); const [tours, setTours] = useState(START); return ( setTours((current) => current.map((tour) => applyIntent(tour, intent)))} > ); } ``` #### Keyboard | Key | Action | |---|---| | `Alt+Shift+← Alt+Shift+→` | Proposes ending the active subtask one raster step earlier or later. | The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`leadIn`](#/subtasks/Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. | | [`leadOut`](#/subtasks/Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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 main time never becomes shorter than one raster step, or a minute without a raster. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Snapping Makes a drag land on a raster - the axis' own step, a fixed duration, or a pattern with an offset - so dropped work starts on a time a planner can read. ```ts import { useSchedule, snapTime } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/snapping/ Without `snap` a drag lands on the fine band's step, which follows the zoom. A number is a step in milliseconds; `{ step, offset }` counts from local midnight; `false` places to the minute. Snapping shapes the ghost and so the intent; it never moves data that is not being dragged. [`snapTime`](#/api/snapTime) is the same arithmetic for your own use. #### Examples ##### Snap to the ticks Without `snap`, a drag lands on the fine band's step: quarter hours when zoomed in, whole hours when zoomed out. ```tsx import { useState } from "react"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const TOURS: readonly Task[] = [{ id: "river", name: "Riverside depot tours", color: "light-dark(#2563eb, #6b9bff)" }]; const START: readonly Subtask[] = [{ id: "T-04", task: "river", lane: "van-402", from: at(7, 30), to: at(12, 10) }]; export default function SnapToTheTicks() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(16)] } }); const [tours, setTours] = useState(START); return ( setTours((current) => current.map((tour) => applyIntent(tour, intent)))} > ); } ``` ##### Snap to a duration Give `snap` a number of milliseconds - here half a day - and every drag lands on that raster, whatever the zoom. ```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 = 0) => new Date(2026, 2, d, hours).getTime(); const HALF_DAY = 12 * 60 * 60_000; const PROJECTS: readonly Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }]; const START: readonly Subtask[] = [ { id: "w-104", task: "shop", lane: "chloe", from: day(17), to: day(18, 12), name: "Basket keeps items across devices" }, { id: "w-107", task: "shop", lane: "noah", from: day(18, 12), to: day(20), name: "Search results layout" }, ]; export default function SnapToADuration() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16), day(21)] } }); const [work, setWork] = useState(START); return ( setWork((current) => current.map((item) => applyIntent(item, intent)))} > ); } ``` ##### Snap to handover times A `snap` raster with a step and an offset: eight-hour watches handed over at 06:00, 14:00 and 22:00 local time. ```tsx import { useState } from "react"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { SnapRaster, Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const HOUR = 3_600_000; /* Offset from local midnight, whatever the time zone. */ const HANDOVERS: SnapRaster = { step: 8 * HOUR, offset: 6 * HOUR }; const ENGINEERS: readonly Task[] = [{ id: "ada", name: "Ada Mwangi", color: "light-dark(#7c3aed, #a98bfa)" }]; const START: readonly Subtask[] = [{ id: "watch-1", task: "ada", lane: "primary", from: at(6), to: at(14) }]; export default function SnapToHandovers() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(4), at(24)] } }); const [watches, setWatches] = useState(START); return ( setWatches((current) => current.map((watch) => applyIntent(watch, intent)))} > ); } ``` ##### Place without snapping `snap={false}` lets a drag land on any minute - for times that follow the pointer rather than a raster. ```tsx import { useState } from "react"; import { applyIntent, useSchedule } from "@umriss-ui/schedule"; import type { Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const ENGINEERS: readonly Task[] = [{ id: "sam", name: "Sam Okafor", color: "light-dark(#0d9488, #3cc7b8)" }]; const START: readonly Subtask[] = [{ id: "window-1", task: "sam", lane: "notifications", from: at(13, 5), to: at(13, 50), name: "Maintenance window" }]; export default function NoSnapping() { const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(10), at(18)] } }); const [windows, setWindows] = useState(START); return ( setWindows((current) => current.map((one) => applyIntent(one, intent)))} > ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`snap`](#/schedule/ScheduleProps-snap) | `"ticks" \| number \| `[`SnapRaster`](#/schedule/type-SnapRaster)` \| false` | `"ticks"` | The raster a drag lands on: `"ticks"` - the fine band's current step -, a step in milliseconds from local midnight, a step with an offset (shifts at 06:00, 14:00 and 22:00 are eight hours offset by six), or `false` for none. It shapes the ghost and the intent, never the data. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Placing from outside Drag work that is not on the plan yet - from a backlog or a list of unassigned jobs - onto a lane, with the browser's own drag and drop. The drop reports where it should go; your application creates it. ```ts import { useSchedule, subtaskFromPlace } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/placing/ The browser hands over dragged data only on the drop, so declare what is being dragged in `placing` on your `dragstart` and clear it on `dragend`. The schedule then shows the ghost at the snapped time, with the findings the drop would create. A `place` intent carries your item's key, the task, the lane and the times. The id of the new subtask is yours; [`subtaskFromPlace`](#/api/subtaskFromPlace) builds it from the intent. #### Examples ##### Drag work in from the backlog Set `placing` on your list's `dragstart` and clear it on `dragend`; the drop reports a `place` intent that [`subtaskFromPlace`](#/api/subtaskFromPlace) turns into work. ```tsx import { useState } from "react"; import { Card, Stack, Text } from "@umriss-ui/core"; import { applyIntent, subtaskFromPlace, useSchedule } from "@umriss-ui/schedule"; import type { Dependency, Intent, PlacingItem, Subtask, Task } from "@umriss-ui/schedule"; const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime(); const HOUR = 3_600_000; const PEOPLE = [ { id: "arjun", label: "Arjun Mehta" }, { id: "chloe", label: "Chloe Durand" }, { id: "eva", label: "Eva Novak" }, ]; const PROJECTS: readonly Task[] = [ { id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }, { id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }, ]; const TODAY: readonly Subtask[] = [ { id: "w-101", task: "portal", lane: "arjun", from: at(8), to: at(9, 30), name: "Sign-in with e-mail code" }, { id: "w-103", task: "portal", lane: "chloe", from: at(8), to: at(8, 45), name: "Session handling" }, { id: "w-104", task: "shop", lane: "chloe", from: at(9, 30), to: at(10, 45), name: "Basket across devices" }, { id: "w-108", task: "portal", lane: "eva", from: at(11, 30), to: at(12, 15), name: "Test plan for sign-in" }, ]; const HANDOVERS: readonly Dependency[] = [{ id: "h-101", from: "w-101", to: "w-108", lag: 20 * 60_000 }]; /* The backlog: what the drag carries, and a label for the list. */ const BACKLOG: readonly (PlacingItem & { label: string })[] = [ { item: "w-121", label: "Fix sign-in redirect · 2 h", task: "portal", duration: 2 * HOUR }, { item: "w-122", label: "Update privacy text · 1 h", task: "shop", duration: HOUR }, { item: "w-123", label: "Accessibility audit · 4 h", task: "shop", duration: 4 * HOUR }, ]; export default function DragFromTheBacklog() { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } }); const [plan, setPlan] = useState<{ work: readonly Subtask[]; placed: number; last: string }>({ work: TODAY, placed: 0, last: "Drag an item onto a person", }); const [placing, setPlacing] = useState(null); /* One functional update, so that a second intent in the same tick - and the counter the new id is built from - starts from the plan the first left. */ const onIntent = (intent: Intent) => setPlan((current) => { if (intent.kind !== "place") { return { ...current, work: current.work.map((item) => applyIntent(item, intent)), last: `${intent.kind} ${intent.subtask}` }; } const placed = current.placed + 1; const id = `${intent.item}-${placed}`; return { work: [...current.work, subtaskFromPlace(intent, id)], placed, last: `place ${intent.item} on ${intent.lane} as ${id}` }; }); return ( {BACKLOG.map((entry) => (
{ event.dataTransfer.setData("text/plain", entry.item); event.dataTransfer.effectAllowed = "copy"; setPlacing(entry); }} onDragEnd={() => setPlacing(null)} style={{ cursor: "grab", padding: "var(--u-space-2) var(--u-space-3)" }} > {entry.label}
))}
{PEOPLE.map((person) => ( ))} {plan.last}
); } ``` #### When to use something else - The work is already on the plan → Move and lane #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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)). | | [`placing`](#/schedule/ScheduleProps-placing) | [`PlacingItem`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ### Where a subtask may go 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(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 ( {last} ); } ``` ##### 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(START); const [placing, setPlacing] = useState(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 (
{ 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)" }} > {WAITING.label}
{last}
); } ``` ##### 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(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 ( {APPS.map((person) => ( ))} {last} ); } ``` #### 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](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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`](#/schedule/ScheduleProps-canMoveTo) | `(subtask: `[`Subtask`](#/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`](#/schedule/ScheduleProps-placing) | [`PlacingItem`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `BlockedTimesProps` — the full table stands on [Blocked time](#/blocked-time/type-BlockedTimesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/blocked-time/BlockedTimesProps-data) *required* | `readonly `[`BlockedTime`](#/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](#/blocked-time/type-BlockedTime). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/blocked-time/BlockedTime-id) *required* | `string` | — | The caller's identity of the interval - what a finding names. | | [`lane`](#/blocked-time/BlockedTime-lane) *required* | `string` | — | The id of the lane it blocks. | | [`from`](#/blocked-time/BlockedTime-from) *required* | `number` | — | Where it begins. | | [`to`](#/blocked-time/BlockedTime-to) *required* | `number` | — | Where it ends. | | [`label`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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/). ### 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. ```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`](#/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`](#/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(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 ( {pushed.length === 0 ? "Nothing to push" : pushed.map((m) => m.subtask).join(", ")} ); } ``` ##### Move a whole tour [`shiftTask`](#/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(START); const move = (by: number) => setStops((current) => shiftTask(current, "T-01", by).reduce((data, one) => data.map((s) => applyIntent(s, one)), current)); return ( ); } ``` ##### Ripple on every drag Run [`ripple`](#/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(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 ( ); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`intents`](#/schedule/ScheduleProps-intents) | `readonly `[`IntentKind`](#/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`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onIntent`](#/schedule/ScheduleProps-onIntent) | `(intent: `[`Intent`](#/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`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | | [`label`](#/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](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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/). ## Measuring What the schedule costs, measured in this browser rather than claimed. ### Benchmark A plan of up to five thousand subtasks and as many dependencies, measured in this browser: the first render, and the time and frames of a scripted pointer walk, pan and wheel zoom. ```ts import { useSchedule } from "@umriss-ui/schedule"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/schedule/benchmark/ The figures in `packages/schedule/docs/capabilities.md` come from here. The page dispatches the pointer and wheel events itself, on the schedule's canvas, and adds no prop to the schedule. #### Examples ##### Measure a plan Pick a plan size and run the scripted pointer walk, pan and wheel zoom: the times and frame counts say what drawing and moving it cost here. ```tsx /* A plan whose size is set by a button, and the figures of what drawing and moving it cost. Everything is measured from outside: React's profiler, the clock and the frame callbacks; the pointer walk, the pan and the wheel zoom are dispatched on the schedule's canvas by the page itself, so a script only presses a button and waits for the status to read `done`. Not photographed: it measures the moment it runs. */ import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Text } from "@umriss-ui/core"; import { useSchedule } from "@umriss-ui/schedule"; import type { Dependency, ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule"; const MINUTE = 60_000; const HOUR = 60 * MINUTE; const T0 = Date.UTC(2026, 2, 16, 6); const VIEW: readonly [number, number] = [T0, T0 + 8 * HOUR]; const HEIGHT = 440; const LANE_HEIGHT = 44; /* Events per scripted run, one to a frame. */ const EVENTS = 240; const SIZES = { small: { lanes: 20, subtasks: 200 }, medium: { lanes: 50, subtasks: 1_000 }, large: { lanes: 100, subtasks: 5_000 }, } as const; type Size = keyof typeof SIZES; interface Plan { lanes: readonly string[]; tasks: readonly Task[]; subtasks: readonly Subtask[]; dependencies: readonly Dependency[]; } /** Lane 0 holds one long bar, the probe the pointer walk runs along. The other lanes share the remaining subtasks in 20-minute bars every half hour, each lane one task, and every bar after the first on a lane hangs on the bar before it - as many dependencies as there are such pairs. */ function plan({ lanes, subtasks }: { lanes: number; subtasks: number }): Plan { const laneIds = Array.from({ length: lanes }, (_, i) => `lane-${i}`); const tasks = laneIds.map((_, i): Task => ({ id: `task-${i}`, name: `Task ${i}`, color: `hsl(${(i * 37) % 360} 60% 50%)` })); const work: Subtask[] = [{ id: "probe", task: "task-0", lane: "lane-0", from: T0 + HOUR, to: T0 + 7 * HOUR, name: "Probe" }]; const dependencies: Dependency[] = []; const others = lanes - 1; for (let j = 0; j < subtasks; j++) { const lane = 1 + (j % others); const from = T0 + Math.floor(j / others) * 30 * MINUTE; work.push({ id: `s-${j}`, task: `task-${lane}`, lane: `lane-${lane}`, from, to: from + 20 * MINUTE, name: `Work ${j}` }); if (j >= others) dependencies.push({ id: `d-${j}`, from: `s-${j - others}`, to: `s-${j}`, lag: 0 }); } return { lanes: laneIds, tasks, subtasks: work, dependencies }; } type Figures = Record; const FIGURES = [ ["first-render-ms", "First render, click to frame"], ["walk-bar-ms", "Pointer walk along one bar, ms per event"], ["walk-bar-renders", "Pointer walk along one bar, renders"], ["walk-bar-frame-ms", "Pointer walk along one bar, ms from event to end of its frame"], ["walk-many-ms", "Pointer walk across many bars, ms per event"], ["walk-many-renders", "Pointer walk across many bars, renders"], ["walk-many-frame-ms", "Pointer walk across many bars, ms from event to end of its frame"], ["pan-ms", "Pan, ms per event"], ["pan-frame-ms", "Pan, ms from event to end of its frame"], ["zoom-ms", "Wheel zoom, ms per event"], ["zoom-frame-ms", "Wheel zoom, ms from event to end of its frame"], ] as const; /** What the page keeps between renders without drawing it: when the size was asked for, the the target last under the pointer. */ interface Meter { clickAt: number; hover: ScheduleInteraction | null; } /* One page, one meter: a module value, since no render may read or write it. */ const meter: Meter = { clickAt: 0, hover: null }; const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); const elapsed = (meter: Meter) => performance.now() - meter.clickAt; const remember = (e: ScheduleInteraction) => { meter.hover = e; }; const stamp = (meter: Meter) => { meter.clickAt = performance.now(); }; const pointer = (type: string, target: Element, x: number, y: number) => target.dispatchEvent( new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, pointerType: "mouse", button: 0, buttons: type === "pointerup" ? 0 : 1, clientX: x, clientY: y }), ); /* The y of lane 0's row: moving down the probe's column until the schedule reports the probe under the pointer. */ function probeY(meter: Meter, el: Element, rect: DOMRect): number { for (let y = 0; y < HEIGHT; y += 3) { pointer("pointermove", el, rect.left + rect.width / 2, rect.top + y); const h = meter.hover; if (h?.hit.kind === "subtask" && h.hit.subtask.id === "probe") return rect.top + y; } return rect.top + LANE_HEIGHT; } interface Sinks { put: (values: Figures) => void; status: (s: "running" | "done") => void; } type Step = (el: Element, rect: DOMRect, y0: number, i: number) => void; /* The end of the next frame: a task queued from a frame callback runs after that frame has been drawn, and the schedule's own draw callback, registered by the event before ours, has run by then. */ const frameEnd = () => new Promise((resolve) => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); resolve(); }; requestAnimationFrame(() => channel.port2.postMessage(null)); }); /* A scripted measurement: EVENTS events, one to a frame. It reports the time the dispatch itself took per event, the time from the dispatch to the end of the frame that drew it, and the batches of markup changes counted meanwhile. The synthetic pointer id is unknown to the browser, so the plot's pointer capture is switched off for the run. */ async function measure(name: string, root: HTMLElement | null, meter: Meter, sinks: Sinks, step: Step, drag: boolean): Promise { const el = root?.querySelector("[data-schedule-plot] canvas"); const plot = el?.parentElement; if (el === null || el === undefined || plot === null || plot === undefined) return; sinks.status("running"); plot.setPointerCapture = () => undefined; const rect = el.getBoundingClientRect(); const y0 = probeY(meter, el, rect); const middle = rect.left + rect.width / 2; if (drag) pointer("pointerdown", el, middle, y0); await frame(); /* The production build has no profiler; a commit shows as a batch of changes to the schedule's markup, which is what is counted. */ let renders = 0; const observer = new MutationObserver(() => renders++); observer.observe(root!, { subtree: true, childList: true, attributes: true, characterData: true }); let busy = 0; let drawn = 0; for (let i = 0; i < EVENTS; i++) { await frame(); const t = performance.now(); step(el, rect, y0, i); busy += performance.now() - t; await frameEnd(); drawn += performance.now() - t; } if (drag) pointer("pointerup", el, middle, y0); await frame(); observer.disconnect(); delete (plot as Partial).setPointerCapture; sinks.put({ [`${name}-ms`]: busy / EVENTS, [`${name}-frame-ms`]: drawn / EVENTS, ...(name.startsWith("walk") ? { [`${name}-renders`]: renders } : {}), }); sinks.status("done"); } const rows = Math.max(1, Math.floor(HEIGHT / LANE_HEIGHT) - 1); const STEPS: Record = { "walk-bar": { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width * (0.12 + (0.76 * i) / EVENTS), y0), drag: false }, "walk-many": { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width * ((i * 0.618) % 1), y0 + (i % rows) * LANE_HEIGHT), drag: false }, /* The pan goes left for half the events and back again, so the view ends where it began. */ pan: { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width / 2 + (i < EVENTS / 2 ? -3 * i : -3 * (EVENTS - i)), y0), drag: true }, /* Ctrl held, as a pinch arrives; in and out in turn. */ zoom: { step: (el, r, y0, i) => el.dispatchEvent( new WheelEvent("wheel", { bubbles: true, cancelable: true, ctrlKey: true, deltaY: i % 2 === 0 ? -120 : 120, clientX: r.left + r.width / 2, clientY: y0 }), ), drag: false, }, }; const RUNS = [ ["walk-bar", "Walk along one bar"], ["walk-many", "Walk across many"], ["pan", "Pan"], ["zoom", "Wheel zoom"], ] as const; function Plot({ data, onInteraction }: { data: Plan; onInteraction: (e: ScheduleInteraction) => void }) { const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: VIEW } }); return ( {data.lanes.map((id) => ( ))} ); } export default function Benchmark() { const [size, setSize] = useState("small"); /* Pressing a size again draws the plan anew, so the first render can be taken twice. */ const [round, setRound] = useState(0); const [status, setStatus] = useState<"idle" | "running" | "done">("running"); const [figures, setFigures] = useState({}); const data = useMemo(() => plan(SIZES[size]), [size]); const host = useRef(null); const sinks: Sinks = { put: (values) => setFigures((f) => ({ ...f, ...values })), status: setStatus, }; /* The second frame after the commit has the canvases drawn. */ useEffect(() => { let live = true; void frame() .then(frame) .then(() => { if (!live) return; setFigures((f) => ({ ...f, "first-render-ms": elapsed(meter) })); setStatus("done"); }); return () => { live = false; }; }, [size, round]); const show = (next: Size) => { stamp(meter); setStatus("running"); setFigures({}); setSize(next); setRound((r) => r + 1); }; const busy = status === "running"; return ( <>
{(Object.keys(SIZES) as Size[]).map((s) => ( ))} {RUNS.map(([name, label]) => ( ))}
Status {status}
Lanes {data.lanes.length}
Subtasks {data.subtasks.length}
Dependencies {data.dependencies.length}
{FIGURES.map(([role, name]) => { const value = figures[role]; return (
{name} {value === undefined ? "-" : value.toFixed(role.endsWith("ms") ? 2 : 0)}
); })}
); } ``` #### Keyboard The keys of [First schedule](#/schedule/keyboard-schedule) apply here. #### Props on this page From `ScheduleProps` — the full table stands on [First schedule](#/schedule/type-ScheduleProps). | Prop | Type | Default | Description | |---|---|---|---| | [`height`](#/schedule/ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. | | [`laneHeight`](#/schedule/ScheduleProps-laneHeight) | `number` | `44` | Height of one lane in pixels. | | [`children`](#/schedule/ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onInteraction`](#/schedule/ScheduleProps-onInteraction) | `(interaction: `[`ScheduleInteraction`](#/interactions/type-ScheduleInteraction)`) => void` | — | Click, context menu and hover, with what the pointer is on and where. While it is set, a right-click opens no browser menu. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/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](#/lane/type-LaneProps). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/lane/LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. | From `SubtasksProps` — the full table stands on [Subtasks](#/subtasks/type-SubtasksProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/subtasks/SubtasksProps-data) *required* | `readonly `[`Subtask`](#/subtasks/type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. | | [`tasks`](#/subtasks/SubtasksProps-tasks) *required* | `readonly `[`Task`](#/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](#/subtasks/type-Subtask). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Subtask-id) *required* | `string` | — | The caller's identity of the subtask. | | [`task`](#/subtasks/Subtask-task) *required* | `string` | — | The id of the task it belongs to. | | [`lane`](#/subtasks/Subtask-lane) *required* | `string` | — | The id of the lane it sits on. | | [`from`](#/subtasks/Subtask-from) *required* | `number` | — | Start of the main time. | | [`to`](#/subtasks/Subtask-to) *required* | `number` | — | End of the main time. | | [`name`](#/subtasks/Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. | From `Task` — the full table stands on [Subtasks](#/subtasks/type-Task). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/subtasks/Task-id) *required* | `string` | — | The caller's identity of the task. | | [`color`](#/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`](#/subtasks/Task-name) | `string` | — | What the task is called, where the schedule names it. | From `DependenciesProps` — the full table stands on [Dependencies](#/dependencies/type-DependenciesProps). | Prop | Type | Default | Description | |---|---|---|---| | [`data`](#/dependencies/DependenciesProps-data) *required* | `readonly `[`Dependency`](#/dependencies/type-Dependency)`[]` | — | The dependencies, as the caller holds them. | From `Dependency` — the full table stands on [Dependencies](#/dependencies/type-Dependency). | Prop | Type | Default | Description | |---|---|---|---| | [`id`](#/dependencies/Dependency-id) *required* | `string` | — | The caller's identity of the dependency. | | [`from`](#/dependencies/Dependency-from) *required* | `string` | — | The id of the subtask it leaves. | | [`to`](#/dependencies/Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. | | [`lag`](#/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 `ScheduleInteraction` — the full table stands on [Interactions](#/interactions/type-ScheduleInteraction). | Prop | Type | Default | Description | |---|---|---|---| | [`hit`](#/interactions/ScheduleInteraction-hit) *required* | [`ScheduleHit`](#/interactions/type-ScheduleHit) | — | What the pointer is on. | From `ScheduleOptions` — the full table stands on [View](#/view/type-ScheduleOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`initialView`](#/view/ScheduleOptions-initialView) | [`ScheduleView`](#/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](#/view/type-ScheduleView). | Prop | Type | Default | Description | |---|---|---|---| | [`domain`](#/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`. | ## API index Every name the package exports, in one place. ### API index Everything `@umriss-ui/schedule` exports, with its signature. A component's props stand on its own page. Demo page: https://romanhaendler.github.io/umriss-ui/schedule/api/ #### Hooks ##### `useSchedule` Declares a schedule: hands back `Schedule` and the parts declared inside it - `Lane`, `LaneGroup`, `Subtasks`, `Dependencies`, `BlockedTimes`, `Milestones` -, its `view` and the setters `setView`, `setDomain`, `toggleGroup`, `foldAll` and `unfoldAll`. A ref on `Schedule` still gives the `ScheduleHandle`. ```ts function useSchedule(options?: ScheduleOptions): ScheduleParts; ``` Used on [Scenarios](#/), [Installation](#/installation), [First schedule](#/schedule), [Lanes](#/lane), [Lane groups](#/lane-groups), [Subtasks](#/subtasks), [Bar labels](#/bar-labels), [Appearances](#/appearances), [Overlap](#/overlap), [Dependencies](#/dependencies), [Routes](#/routes), [Time axis and calendar](#/time-axis), [Blocked time](#/blocked-time), [Milestones](#/milestones), [Now line](#/now-line), [Pan and zoom](#/pan-and-zoom), [Selection](#/selection), [Interactions](#/interactions), [Tooltip](#/tooltip), [View](#/view), [Linked schedules](#/linked-schedules), [The handle](#/handle), [Findings as data](#/findings), [Move and lane](#/move-and-lane), [Stretch, lead-in, lead-out](#/stretch), [Snapping](#/snapping), [Placing from outside](#/placing), [Where a subtask may go](#/where-it-may-go), [Ripple](#/ripple) and [Benchmark](#/benchmark). #### Functions ##### `applyIntent` The subtask as the intent asks for it. An intent for another subtask leaves it as it is. ```ts function applyIntent(subtask: S, intent: Intent): S; ``` Used on [Scenarios](#/), [Lane groups](#/lane-groups), [Time axis and calendar](#/time-axis), [Blocked time](#/blocked-time), [Interactions](#/interactions), [Move and lane](#/move-and-lane), [Stretch, lead-in, lead-out](#/stretch), [Snapping](#/snapping), [Placing from outside](#/placing), [Where a subtask may go](#/where-it-may-go) and [Ripple](#/ripple). ##### `arrival` When a dependency has to have arrived at its subtask, by its anchor. ```ts function arrival(dependency: Dependency, to: Subtask): number; ``` Used on [Scenarios](#/), [Now line](#/now-line) and [Findings as data](#/findings). ##### `departure` When a dependency leaves its subtask, by its anchor. ```ts function departure(dependency: Dependency, from: Subtask): number; ``` Used on [Findings as data](#/findings). ##### `findings` Every finding at once. Without blocked time there is none in it. ```ts function findings(subtasks: readonly Subtask[], dependencies: readonly Dependency[], blocked?: readonly BlockedTime[]): Findings; ``` Used on [Scenarios](#/), [First schedule](#/schedule), [Lane groups](#/lane-groups), [Blocked time](#/blocked-time), [Tooltip](#/tooltip) and [Findings as data](#/findings). ##### `inBlockedTime` Every subtask that covers blocked time on its lane, lead-in and lead-out included - once per blocked interval it covers, in the order of the subtasks. Touching at a shared edge is not covering. ```ts function inBlockedTime(subtasks: readonly Subtask[], blocked: readonly BlockedTime[]): InBlockedTime[]; ``` Used on [Scenarios](#/), [Blocked time](#/blocked-time) and [Findings as data](#/findings). ##### `occupied` The interval a subtask occupies on its lane: lead-in and lead-out included. ```ts function occupied(subtask: Subtask): { from: number; to: number; }; ``` ##### `overlaps` Every pair of subtasks that cover each other on a lane. Touching at a shared edge is not covering, and a subtask that covers no time covers nothing. ```ts function overlaps(subtasks: readonly Subtask[]): Overlap[]; ``` Used on [Scenarios](#/), [Lane groups](#/lane-groups), [Findings as data](#/findings) and [Move and lane](#/move-and-lane). ##### `resolveAppearance` Settles a subtask's appearances into what is drawn. `provisional` and `fixed` contradict each other; the later one in the list wins. - `appearance` – The subtask's `appearance`, in the order it was given. - Returns Which of the four channels are on. ```ts function resolveAppearance(appearance: readonly SubtaskAppearance[] | undefined): ResolvedAppearance; ``` Used on [Appearances](#/appearances). ##### `ripple` The moves that push every successor whose dependency no longer fits after `intent` - transitively, in the order they were first pushed. The subtask the intent is about is never moved, and a cycle among the successors ends after a bounded number of pushes instead of running on. ```ts function ripple(subtasks: readonly Subtask[], dependencies: readonly Dependency[], intent: Intent): MoveIntent[]; ``` Used on [Scenarios](#/), [Dependencies](#/dependencies) and [Ripple](#/ripple). ##### `shiftTask` The moves that carry a whole task in time - every subtask of it by the same amount, so that its lengths and the distances between its stops stay as they were. The schedule never runs it: apply the moves like any other intent, or run [`ripple`](#/api/ripple) over them first. - `subtasks` – All subtasks; only those of `task` are moved. - `task` – The id of the task, as its subtasks name it in `task`. - `by` – How far, in milliseconds; negative is earlier. - Returns One move intent per subtask of the task - none where `by` is zero or not finite. ```ts function shiftTask(subtasks: readonly Subtask[], task: string, by: number): MoveIntent[]; ``` Used on [Scenarios](#/) and [Ripple](#/ripple). ##### `snapTime` `time` onto the nearest line of the raster in local time - a step alone starts at local midnight. A step of zero or less is no raster: the time stays as it is. ```ts function snapTime(time: number, raster: number | SnapRaster): number; ``` Used on [Snapping](#/snapping). ##### `subtaskFromPlace` The subtask a place intent asks for, under an id the caller chooses. ```ts function subtaskFromPlace(intent: PlaceIntent, id: string): Subtask; ``` Used on [Placing from outside](#/placing) and [Where a subtask may go](#/where-it-may-go). ##### `violatedDependencies` Every dependency whose successor is to be reached before the predecessor's departure plus the dependency's lag. A dependency naming a subtask that is not in the data is passed over: there is nothing to judge. ```ts function violatedDependencies(subtasks: readonly Subtask[], dependencies: readonly Dependency[]): ViolatedDependency[]; ``` Used on [Scenarios](#/), [Lane groups](#/lane-groups), [Tooltip](#/tooltip) and [Findings as data](#/findings). #### Types ##### `BlockedTime` Its table stands on [Blocked time](#/blocked-time/type-BlockedTime). Used on [Scenarios](#/), [Blocked time](#/blocked-time), [Tooltip](#/tooltip), [Findings as data](#/findings) and [Where a subtask may go](#/where-it-may-go). ##### `BlockedTimesProps` Its table stands on [Blocked time](#/blocked-time/type-BlockedTimesProps). ##### `DependenciesProps` Its table stands on [Dependencies](#/dependencies/type-DependenciesProps). ##### `Dependency` Its table stands on [Dependencies](#/dependencies/type-Dependency). Used on [Scenarios](#/), [Installation](#/installation), [First schedule](#/schedule), [Lane groups](#/lane-groups), [Dependencies](#/dependencies), [Routes](#/routes), [Time axis and calendar](#/time-axis), [Selection](#/selection), [Interactions](#/interactions), [Tooltip](#/tooltip), [Findings as data](#/findings), [Move and lane](#/move-and-lane), [Placing from outside](#/placing), [Ripple](#/ripple) and [Benchmark](#/benchmark). ##### `DependencyAttachment` Where on its bars a dependency's line attaches: the middle of both, or the edge that faces the other stop - which is the shortest line between them. Deliberately not called an anchor: a dependency already HAS two anchors (`leaves`, `arrives`), and those decide what it connects and therefore whether it is violated. This decides where the line touches, and nothing else. The tree has an **Anchor** of its own as well (CONTEXT.md). ```ts type DependencyAttachment = "centre" | "nearest"; ``` Used on [Dependencies](#/dependencies). ##### `DependencyEnds` Whether a dependency's two ends carry a dot. The dot says where the line is anchored, which is worth saying while a plan is being read and is noise in a plan full of short moves - so it is the caller's choice. ```ts type DependencyEnds = "dot" | "none"; ``` Used on [Dependencies](#/dependencies). ##### `DependencyRoute` How a dependency is drawn between its two ends: a curve that leaves and arrives forwards, a straight line, or axis-parallel segments. ```ts type DependencyRoute = "curve" | "straight" | "orthogonal"; ``` ##### `Findings` Every kind of finding over the same data. | Prop | Type | Default | Description | |---|---|---|---| | `overlaps` *required* | `readonly `[`Overlap`](#/findings/type-Overlap)`[]` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [List the findings (Findings as data)](#/findings/list-the-findings), [Say when there is nothing to fix (Findings as data)](#/findings/nothing-to-fix) and 1 more | | `violatedDependencies` *required* | `readonly `[`ViolatedDependency`](#/findings/type-ViolatedDependency)`[]` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [List the findings (Findings as data)](#/findings/list-the-findings), [Say when there is nothing to fix (Findings as data)](#/findings/nothing-to-fix) and 1 more | | `inBlockedTime` *required* | `readonly `[`InBlockedTime`](#/findings/type-InBlockedTime)`[]` | — |
Shown in: [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Check a sprint against the leave calendar (Blocked time)](#/blocked-time/team-leave), [List the findings (Findings as data)](#/findings/list-the-findings) and 3 more | ##### `InBlockedTime` Its table stands on [Findings as data](#/findings/type-InBlockedTime). ##### `Intent` What the schedule reports when an interaction asks for a change. Every intent carries the values it asks for, not a difference: two intents of one drop - a move and a lane - give the same data in either order. ```ts type Intent = MoveIntent | StretchIntent | LeadInIntent | LeadOutIntent | LaneIntent | PlaceIntent; ``` Used on [Scenarios](#/), [Blocked time](#/blocked-time), [Move and lane](#/move-and-lane), [Placing from outside](#/placing), [Where a subtask may go](#/where-it-may-go) and [Ripple](#/ripple). ##### `IntentKind` The names of the intents - the editing API of the schedule. ```ts type IntentKind = Intent["kind"]; ``` ##### `LaneGroupProps` Its table stands on [Lane groups](#/lane-groups/type-LaneGroupProps). ##### `LaneIntent` Put the subtask on another lane. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"lane"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `lane` *required* | `string` | — | The id of the new lane. | ##### `LaneProps` Its table stands on [Lanes](#/lane/type-LaneProps). ##### `LeadInIntent` Change the lead-in on its own. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"leadIn"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `leadIn` *required* | `number` | — | The new duration of the lead-in.
Shown in: [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | ##### `LeadOutIntent` Change the lead-out on its own. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"leadOut"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `leadOut` *required* | `number` | — | The new duration of the lead-out. | ##### `Milestone` Its table stands on [Milestones](#/milestones/type-Milestone). Used on [Milestones](#/milestones). ##### `MilestonesProps` Its table stands on [Milestones](#/milestones/type-MilestonesProps). ##### `MoveIntent` Move the subtask in time: the main time keeps its length. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"move"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 8 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 5 more | | `from` *required* | `number` | — | The new start of the main time.
Shown in: [Push the later stops of a tour (Ripple)](#/ripple/push-the-later-stops), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `to` *required* | `number` | — | The new end of the main time.
Shown in: [Push the later stops of a tour (Ripple)](#/ripple/push-the-later-stops), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | Used on [Ripple](#/ripple). ##### `Overlap` Its table stands on [Findings as data](#/findings/type-Overlap). ##### `PlaceIntent` Put work on the plan that was not on it: the answer to a drag from outside the schedule. It carries no subtask id - there is no subtask yet; the caller creates one, with its own identity, from `subtaskFromPlace`. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"place"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `item` *required* | `string` | — | The caller's key for the dragged item, as it declared it while dragging.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in), [Replan the day on the line (Scenarios)](#/scenarios/replan-the-day-on-the-line) | | `task` *required* | `string` | — | The task the work belongs to. | | `lane` *required* | `string` | — | The lane it was dropped on.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `from` *required* | `number` | — | Where its main time would start. | | `to` *required* | `number` | — | Where its main time would end. | | `leadIn` | `number` | — | The lead-in it was declared with. | | `leadOut` | `number` | — | The lead-out it was declared with. | ##### `PlacingItem` What the application says it is dragging in, while it drags it: the browser hands over the dragged data only on the drop, so the ghost before the drop can only come from the caller. | Prop | Type | Default | Description | |---|---|---|---| | `item` *required* | `string` | — | The caller's key for the dragged item; it comes back in the intent.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `task` *required* | `string` | — | The task the work belongs to.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `duration` *required* | `number` | — | How long its main time is.
Shown in: [Drag work in from the backlog (Placing from outside)](#/placing/drag-from-the-backlog), [Apply the same rule to work dragged in (Where a subtask may go)](#/where-it-may-go/dragged-in) | | `leadIn` | `number` | — | The lead-in it brings. | | `leadOut` | `number` | — | The lead-out it brings. | Used on [Placing from outside](#/placing) and [Where a subtask may go](#/where-it-may-go). ##### `ResolvedAppearance` What the drawing needs to know, with the contradiction already settled. Two of the field names (`dashed`, `hatched`) are older than the drawing; the channel each one owns stands beside it. | Prop | Type | Default | Description | |---|---|---|---| | `dashed` *required* | `boolean` | — | `provisional`: the FILL - none at all, so the surface shows through, with a dashed outline in the task colour. Planned, not released. | | `hatched` *required* | `boolean` | — | `fixed`: the ENDS - a cap at each end of the main time. Not a hatch: a hatch says "not available" of a refused lane. The field keeps the name `hatched`. | | `muted` *required* | `boolean` | — | `muted`: the SATURATION - the task colour mixed half into the surface, opaque and at full height. Another team's, another crew's. | | `open` *required* | `boolean` | — | `open`: the FADE, where the bar passes the edge of the view. It goes on past what is drawn. | ##### `ScheduleHandle` What a schedule offers a caller imperatively: the arithmetic between a point on the screen and a time on a lane. Everything else is props. | Prop | Type | Default | Description | |---|---|---|---| | `positionAt` *required* | `(clientX: number, clientY: number) => { time: number; lane: string \| null } \| null` | — | The time and the lane at a client point, or null outside the plot.
Shown in: [Place a mark of your own (The handle)](#/handle/place-a-mark) | | `clientPointOf` *required* | `(time: number, lane?: string) => { x: number; y: number } \| null` | — | The client point of a time - on the middle of a lane where one is named - or null while the schedule is not on screen.
Shown in: [Place a mark of your own (The handle)](#/handle/place-a-mark) | | `visibleDomain` *required* | `() => readonly [number, number]` | — | The visible span, as two wall-clock instants. | Used on [The handle](#/handle). ##### `ScheduleHit` What a pointer is on. ```ts type ScheduleHit = | { readonly kind: "subtask"; readonly subtask: Subtask; readonly part: "leadIn" | "main" | "leadOut" } | { readonly kind: "dependency"; readonly dependency: Dependency } | { readonly kind: "milestone"; readonly milestone: Milestone } | { readonly kind: "lane"; readonly lane: string } | { readonly kind: "nothing" }; ``` ##### `ScheduleInteraction` Its table stands on [Interactions](#/interactions/type-ScheduleInteraction). Used on [Scenarios](#/), [Interactions](#/interactions), [Tooltip](#/tooltip) and [Benchmark](#/benchmark). ##### `ScheduleOptions` Its table stands on [View](#/view/type-ScheduleOptions). ##### `ScheduleParts` What `useSchedule` hands back: the schedule and what is declared inside it, its view and the setters that change it. | Prop | Type | Default | Description | |---|---|---|---| | `Schedule` *required* | `(props: `[`ScheduleProps`](#/schedule/type-ScheduleProps)`) => ReactNode` | — | The schedule. One per `useSchedule`: a second schedule is a second call.
Shown in: [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule), [Start from an empty plan (First schedule)](#/schedule/no-work-yet) and 78 more | | `Lane` *required* | `typeof Lane` | — |
Shown in: [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule), [Start from an empty plan (First schedule)](#/schedule/no-work-yet) and 78 more | | `LaneGroup` *required* | `typeof LaneGroup` | — |
Shown in: [Group lanes under a head (Lane groups)](#/lane-groups/a-group), [Nest groups to any depth (Lane groups)](#/lane-groups/nesting), [Fold groups from outside (Lane groups)](#/lane-groups/fold-from-outside) and 5 more | | `Subtasks` *required* | `typeof Subtasks` | — |
Shown in: [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule), [Start from an empty plan (First schedule)](#/schedule/no-work-yet) and 78 more | | `Dependencies` *required* | `typeof Dependencies` | — |
Shown in: [The smallest schedule (Installation)](#/installation/smallest-schedule), [A day of tours (First schedule)](#/schedule/first-schedule), [Plan a day in the machine shop (First schedule)](#/schedule/machine-plan) and 28 more | | `BlockedTimes` *required* | `typeof BlockedTimes` | — |
Shown in: [Block out leave and maintenance (Blocked time)](#/blocked-time/leave), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Check a sprint against the leave calendar (Blocked time)](#/blocked-time/team-leave) and 5 more | | `Milestones` *required* | `typeof Milestones` | — |
Shown in: [Mark a handover and a release (Milestones)](#/milestones/handovers-and-releases), [Say what a milestone is in your own tooltip (Milestones)](#/milestones/say-what-a-milestone-is) | | `view` *required* | [`ScheduleView`](#/view/type-ScheduleView) | — | How the planner is looking at the plan: the span and the folded groups.
Shown in: [Fold groups from outside (Lane groups)](#/lane-groups/fold-from-outside), [Keep and restore a view (View)](#/view/keep-and-restore), [Keep two schedules in step (Linked schedules)](#/linked-schedules/in-step) and 1 more | | `setView` *required* | `(view: `[`ScheduleView`](#/view/type-ScheduleView)`) => void` | — | Goes to a whole view; what it leaves out is shown whole again. Not reported through `onViewChange`: two schedules kept in step by `onViewChange: (v) => other.setView(v)` in both directions do not answer each other. A view with the content already shown changes nothing.
Shown in: [Keep and restore a view (View)](#/view/keep-and-restore), [Keep two schedules in step (Linked schedules)](#/linked-schedules/in-step), [Read a rota beside the incidents (Linked schedules)](#/linked-schedules/rota-beside-incidents) | | `setDomain` *required* | `(span: readonly [number, number] \| null) => void` | — | Puts a span of two wall-clock instants in view; `null` shows the subtasks' extent.
Shown in: [Keep and restore a view (View)](#/view/keep-and-restore) | | `toggleGroup` *required* | `(id: string) => void` | — | Folds or unfolds one **Lane group**. | | `foldAll` *required* | `() => void` | — | Folds every group.
Shown in: [Fold groups from outside (Lane groups)](#/lane-groups/fold-from-outside) | | `unfoldAll` *required* | `() => void` | — | Unfolds every group.
Shown in: [Fold groups from outside (Lane groups)](#/lane-groups/fold-from-outside) | Used on [Linked schedules](#/linked-schedules). ##### `ScheduleProps` Its table stands on [First schedule](#/schedule/type-ScheduleProps). ##### `ScheduleTooltipTarget` Its table stands on [Tooltip](#/tooltip/type-ScheduleTooltipTarget). Used on [Milestones](#/milestones) and [Tooltip](#/tooltip). ##### `ScheduleView` Its table stands on [View](#/view/type-ScheduleView). Used on [View](#/view). ##### `SnapRaster` A raster: a step, and where it starts after local midnight. | Prop | Type | Default | Description | |---|---|---|---| | `step` *required* | `number` | — | The distance between two lines of the raster, in milliseconds.
Shown in: [Snap to handover times (Snapping)](#/snapping/snap-to-handovers) | | `offset` *required* | `number` | — | Where the raster starts after local midnight, in milliseconds: six hours for handovers at 06:00, 14:00 and 22:00.
Shown in: [Snap to handover times (Snapping)](#/snapping/snap-to-handovers) | Used on [Snapping](#/snapping). ##### `StretchIntent` Stretch the main time at one or both of its edges. | Prop | Type | Default | Description | |---|---|---|---| | `kind` *required* | `"stretch"` | — |
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 7 more | | `subtask` *required* | `string` | — | The id of the subtask.
Shown in: [Read a folded group (Lane groups)](#/lane-groups/the-miniature), [Plan work around someone's leave (Blocked time)](#/blocked-time/drag-around), [Refuse a move that double-books someone (Move and lane)](#/move-and-lane/refuse-a-double-booking) and 4 more | | `from` *required* | `number` | — | The new start of the main time. | | `to` *required* | `number` | — | The new end of the main time. | ##### `Subtask` Its table stands on [Subtasks](#/subtasks/type-Subtask). Used on [Scenarios](#/), [Installation](#/installation), [First schedule](#/schedule), [Lanes](#/lane), [Lane groups](#/lane-groups), [Subtasks](#/subtasks), [Bar labels](#/bar-labels), [Appearances](#/appearances), [Overlap](#/overlap), [Dependencies](#/dependencies), [Routes](#/routes), [Time axis and calendar](#/time-axis), [Blocked time](#/blocked-time), [Milestones](#/milestones), [Now line](#/now-line), [Pan and zoom](#/pan-and-zoom), [Selection](#/selection), [Interactions](#/interactions), [Tooltip](#/tooltip), [View](#/view), [Linked schedules](#/linked-schedules), [The handle](#/handle), [Findings as data](#/findings), [Move and lane](#/move-and-lane), [Stretch, lead-in, lead-out](#/stretch), [Snapping](#/snapping), [Placing from outside](#/placing), [Where a subtask may go](#/where-it-may-go), [Ripple](#/ripple) and [Benchmark](#/benchmark). ##### `SubtaskAppearance` The appearances a caller may give a subtask. ```ts type SubtaskAppearance = "provisional" | "fixed" | "muted" | "open"; ``` ##### `SubtasksProps` Its table stands on [Subtasks](#/subtasks/type-SubtasksProps). ##### `Task` Its table stands on [Subtasks](#/subtasks/type-Task). Used on [Scenarios](#/), [Installation](#/installation), [First schedule](#/schedule), [Lanes](#/lane), [Lane groups](#/lane-groups), [Subtasks](#/subtasks), [Bar labels](#/bar-labels), [Appearances](#/appearances), [Overlap](#/overlap), [Dependencies](#/dependencies), [Routes](#/routes), [Time axis and calendar](#/time-axis), [Blocked time](#/blocked-time), [Milestones](#/milestones), [Now line](#/now-line), [Pan and zoom](#/pan-and-zoom), [Selection](#/selection), [Interactions](#/interactions), [Tooltip](#/tooltip), [View](#/view), [Linked schedules](#/linked-schedules), [The handle](#/handle), [Findings as data](#/findings), [Move and lane](#/move-and-lane), [Stretch, lead-in, lead-out](#/stretch), [Snapping](#/snapping), [Placing from outside](#/placing), [Where a subtask may go](#/where-it-may-go), [Ripple](#/ripple) and [Benchmark](#/benchmark). ##### `ViolatedDependency` Its table stands on [Findings as data](#/findings/type-ViolatedDependency). Used on [Dependencies](#/dependencies). ##### `ZoomLimits` The narrowest and the widest span zoom may reach. | Prop | Type | Default | Description | |---|---|---|---| | `min` *required* | `number` | — |
Shown in: [Move through the plan (Pan and zoom)](#/pan-and-zoom/pan-and-zoom) | | `max` *required* | `number` | — |
Shown in: [Move through the plan (Pan and zoom)](#/pan-and-zoom/pan-and-zoom) |