@umriss-ui/schedule 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3451 @@
1
+ # @umriss-ui/schedule 0.2.0
2
+
3
+ > The umriss schedule - subtasks on lanes over time, with transports, findings and controlled editing.
4
+
5
+ Install with `pnpm add @umriss-ui/schedule`. 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 why it is built the way it is. The pages index stands in https://romanhaendler.github.io/umriss-ui/schedule/llms.txt.
6
+
7
+ ## Drawing
8
+
9
+ What the schedule shows: lanes over time, the work on them, and everything a bar says besides its colour.
10
+
11
+ ### First schedule
12
+
13
+ Subtasks on lanes over time, with a day band above and adaptive time ticks below - the whole picture in thirty lines.
14
+
15
+ ```ts
16
+ import { Schedule, Lane, Subtasks, Transports } from "@umriss-ui/schedule";
17
+ ```
18
+
19
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/schedule
20
+
21
+ #### Examples
22
+
23
+ ##### A day in the plant
24
+
25
+ ```tsx
26
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
27
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
28
+
29
+ /* The schedule draws what it is given: lanes in the order they are declared,
30
+ subtasks in the colour of their task, transports from one subtask's end to
31
+ the next one's start. The day band above names the day, the fine band below
32
+ steps from the hour down to the quarter hour as the time scale is zoomed.
33
+
34
+ Two findings stand in this plan on purpose: two orders claim the mill at
35
+ 10:00, and the bracket cannot reach the paint shop in time. They are drawn,
36
+ offset and marked, and nothing moves them.
37
+
38
+ Drag the background to pan in both directions. The wheel scrolls the lanes
39
+ and, once they are at their end, the page; Ctrl or ⌘ with the wheel, or a
40
+ pinch, zooms; Shift with the wheel pans through time. `Transports` is declared
41
+ before `Subtasks`, so its lines run beneath the bars. */
42
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
43
+ const min = (n: number) => n * 60_000;
44
+
45
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
46
+
47
+
48
+ const STATIONS = [
49
+ { id: "saw", label: "Saw 1" },
50
+ { id: "mill", label: "Mill" },
51
+ { id: "press", label: "Press 2" },
52
+ { id: "paint", label: "Paint shop" },
53
+ ];
54
+
55
+ const ORDERS: readonly Task[] = [
56
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
57
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
58
+ { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
59
+ ];
60
+
61
+ const STEPS: readonly Subtask[] = [
62
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
63
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
64
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
65
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
66
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
67
+ { id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
68
+ { id: "a-2044-3", task: "a-2044", lane: "paint", from: at(14, 45), to: at(16), setup: min(15), teardown: min(15) },
69
+ ];
70
+
71
+ const MOVES: readonly Transport[] = [
72
+ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
73
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
74
+ { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", duration: min(60), arrives: "main" },
75
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
76
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
77
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
78
+ ];
79
+
80
+ export default function FirstSchedule() {
81
+ return (
82
+ <Schedule ariaLabel="Plan of Tuesday, 17 March" initialDomain={DAY_OF_PLAN} height={240}>
83
+ {STATIONS.map((station) => (
84
+ <Lane key={station.id} id={station.id} label={station.label} />
85
+ ))}
86
+ <Transports data={MOVES} />
87
+ <Subtasks data={STEPS} tasks={ORDERS} />
88
+ </Schedule>
89
+ );
90
+ }
91
+ ```
92
+
93
+ #### API
94
+
95
+ ##### `ScheduleProps`
96
+
97
+ | Prop | Type | Default | Description |
98
+ |---|---|---|---|
99
+ | `ariaLabel` (required) | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |
100
+ | `initialDomain` (required) | `readonly [number, number]` | — | The time in view when the schedule mounts, as two wall-clock instants. Pan and zoom take it from there; a new pair of values puts it back. |
101
+ | `height` | `number` | — | Height in CSS pixels, both bands included. The width is the host's. |
102
+ | `laneHeight` | `number` | — | Height of one lane in pixels. |
103
+ | `headerWidth` | `number` | — | Width of the lane headers in pixels - at most 40 % of the schedule, so that on a phone the plot keeps the larger part. |
104
+ | `calendar` | `CalendarInput` | — | An operating calendar: the intervals in which time counts. Nights and weekends outside them are cut out of the axis. Default: the wall clock. |
105
+ | `zoomLimits` | `ZoomLimits` | — | The narrowest and widest time span zoom may reach, in milliseconds. Default: one hour to 28 days. |
106
+ | `snap` | `"ticks" \| number \| SnapRaster \| false` | — | 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. |
107
+ | `intents` | `readonly IntentKind[]` | — | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023). |
108
+ | `canMoveTo` | `(subtask: Subtask, lane: string) => boolean` | — | Whether a subtask may go to a lane. Without it every lane is open; 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`. |
109
+ | `onIntent` | `(intent: Intent) => void` | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |
110
+ | `onInteraction` | `(interaction: 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. |
111
+ | `selectedTask` | `string \| null` | — | The selected task, controlled. Leave it out and the schedule keeps the selection itself. |
112
+ | `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 transport or on nothing. It is called again when another subtask of the same task is clicked. |
113
+ | `collapsedGroups` | `readonly string[]` | — | The **Lane group**s that are folded, controlled. Leave it out and the schedule keeps them itself, starting from `defaultCollapsedGroups`. Folding changes the view and not the plan, which is why it is no intent: a caller that applies every intent it receives must never find a fold among them (ADR-0025). A folded outer group hides the inner ones without their entries leaving the list, so unfolding it gives back the view that was there. |
114
+ | `defaultCollapsedGroups` | `readonly string[]` | — | Which groups are folded when the schedule mounts, where the schedule keeps the state itself. Ignored while `collapsedGroups` is given. |
115
+ | `onCollapsedGroupsChange` | `(groups: readonly string[]) => void` | — | Called with the whole list when the planner folds or unfolds a group. |
116
+ | `onDomainChange` | `(domain: readonly [number, number]) => void` | — | The visible time span after the planner panned or zoomed, as two wall-clock instants - for keeping a second schedule or a chart in step. A span handed in through `initialDomain` is not reported back. |
117
+ | `placing` | `PlacingItem \| null` | — | What the application is dragging in from outside while it drags it - its key, task, the length of its main time, its setup and teardown. 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. |
118
+ | `route` | `TransportRoute` | — | How the transports are drawn: a `"curve"` that leaves and arrives forwards, a `"straight"` line, or `"orthogonal"` segments. A transport may say otherwise for itself. |
119
+ | `attach` | `TransportAttachment` | — | Where a transport'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 transport may say otherwise for itself. It changes the picture and never a finding: whether a transport is late follows from its `leaves` and `arrives` alone. |
120
+ | `ends` | `TransportEnds` | — | Whether a transport's two ends carry a dot (`"dot"`, the default) 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 transport may say otherwise for itself. |
121
+ | `label` | `(subtask: 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. |
122
+ | `tooltip` | `false \| ((target: ScheduleTooltipTarget) => ReactNode)` | — | The tooltip on a hovered subtask or transport: 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. |
123
+ | `now` | `boolean \| number` | — | A line marking the present across the lanes: `true` follows the clock by the minute, an instant fixes it there. Off by default. |
124
+ | `className` | `string` | — | Goes to the root element. |
125
+ | `style` | `CSSProperties` | — | Goes to the root element. |
126
+ | `children` | `ReactNode` | — | `Lane`, `Transports` and `Subtasks`, in the order they are to stand and be drawn. |
127
+
128
+ #### Why it is like this
129
+
130
+ ##### The schedule changes nothing
131
+
132
+ Drag a bar and the plan does not move. The schedule draws a ghost, assesses it as if it were data, and when the drag ends it **reports what was asked for** - a move, a lane, a stretch, a setup, a teardown, a place. The subtasks it was given are the subtasks it still has. See `docs/adr/0023-intents-not-mutations.md`.
133
+
134
+ That is not caution, it is the only arrangement that can be true. A planning application has rules the schedule cannot know - a mould that fits one press, a shift that ends at two, an order that may not start before its material arrives - and a component that wrote to the plan first and asked afterwards would be wrong in exactly the cases that matter. Here the application is asked first, every time, and nothing is ever travelled to and then corrected.
135
+
136
+ ##### Every intent carries values, not differences
137
+
138
+ A move says where the main time now starts and ends; it does not say "two hours later". Two intents from one drop - a move and a lane - can therefore be applied in either order, or one of them alone, or neither. `applyIntent` is the arithmetic, and it is exported so an application can run it without the component.
139
+
140
+ ##### What that costs, said plainly
141
+
142
+ Nothing happens until you write the handler. The simplest useful schedule is a` useState` and one `map`. In exchange you get a component that cannot corrupt a plan, an undo that is yours, and a cascade (`ripple`) you may run or refuse.
143
+
144
+ ### Lanes
145
+
146
+ One machine or station per lane, named by its header - in the order the lanes are declared, however many there are.
147
+
148
+ ```ts
149
+ import { Lane } from "@umriss-ui/schedule";
150
+ ```
151
+
152
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/lane
153
+
154
+ #### Examples
155
+
156
+ ##### What a lane header says
157
+
158
+ ```tsx
159
+ import { Badge, Stack, Text } from "@umriss-ui/core";
160
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
161
+ import type { Subtask, Task } from "@umriss-ui/schedule";
162
+
163
+ /* A lane is declared by its id - the one subtasks name - and a label. The label
164
+ is content, not a string: a name with its resource number, a status beside
165
+ it. It is real text, read by a screen reader, and it stays at the left edge
166
+ while the plot pans.
167
+
168
+ The lanes stand in the order they are declared. A subtask on a lane that is
169
+ not declared is not drawn. */
170
+
171
+ const at = (hours: number) => new Date(2026, 2, 17, hours).getTime();
172
+
173
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#7c3aed, #a98bfa)" }];
174
+
175
+ const WORK: Subtask[] = [
176
+ { id: "one", task: "order", lane: "m-104", from: at(7), to: at(9) },
177
+ { id: "two", task: "order", lane: "m-231", from: at(9), to: at(11) },
178
+ ];
179
+
180
+ export default function LaneHeaders() {
181
+ return (
182
+ <Schedule ariaLabel="Two presses" initialDomain={[at(6), at(12)]} height={150} headerWidth={200}>
183
+ <Lane
184
+ id="m-104"
185
+ label={
186
+ <Stack direction="row" gap={2} align="center">
187
+ <Text size="sm">Press 1</Text>
188
+ <Text size="xs" tone="muted" mono>
189
+ M-104
190
+ </Text>
191
+ </Stack>
192
+ }
193
+ />
194
+ <Lane
195
+ id="m-231"
196
+ label={
197
+ <Stack direction="row" gap={2} align="center">
198
+ <Text size="sm">Press 2</Text>
199
+ <Badge tone="warning">Service</Badge>
200
+ </Stack>
201
+ }
202
+ />
203
+ <Subtasks data={WORK} tasks={TASKS} />
204
+ </Schedule>
205
+ );
206
+ }
207
+ ```
208
+
209
+ ##### More lanes than the height
210
+
211
+ ```tsx
212
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
213
+ import type { Subtask, Task } from "@umriss-ui/schedule";
214
+
215
+ /* Twenty lanes in 300 pixels: the lanes scroll, and the lane headers go with
216
+ them - vertically only. The day band and the time band hold still, and so
217
+ does the column of headers when the plot pans through time.
218
+
219
+ `height` is the whole schedule, both bands included; `laneHeight` and
220
+ `headerWidth` set the rest. Drag the background up and down to reach the
221
+ lower lanes. */
222
+
223
+ const LINES: Task[] = [
224
+ { id: "line-a", color: "light-dark(#2563eb, #6b9bff)" },
225
+ { id: "line-b", color: "light-dark(#0d9488, #3cc7b8)" },
226
+ ];
227
+
228
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
229
+
230
+ const CELLS = Array.from({ length: 20 }, (_, i) => ({ id: `cell-${i + 1}`, label: `Cell ${String(i + 1).padStart(2, "0")}` }));
231
+
232
+ /* One subtask per cell, staggered by a quarter hour down the lanes. */
233
+ const WORK: Subtask[] = CELLS.map((cell, i) => ({
234
+ id: `${cell.id}-job`,
235
+ task: i % 2 === 0 ? "line-a" : "line-b",
236
+ lane: cell.id,
237
+ from: at(7, 15 * i),
238
+ to: at(9, 15 * i),
239
+ setup: 15 * 60_000,
240
+ }));
241
+
242
+ export default function ManyLanes() {
243
+ return (
244
+ <Schedule
245
+ ariaLabel="Twenty cells, Tuesday morning"
246
+ initialDomain={[at(6), at(14)]}
247
+ height={300}
248
+ laneHeight={32}
249
+ headerWidth={110}
250
+ >
251
+ {CELLS.map((cell) => (
252
+ <Lane key={cell.id} id={cell.id} label={cell.label} />
253
+ ))}
254
+ <Subtasks data={WORK} tasks={LINES} />
255
+ </Schedule>
256
+ );
257
+ }
258
+ ```
259
+
260
+ #### API
261
+
262
+ ##### `LaneProps`
263
+
264
+ | Prop | Type | Default | Description |
265
+ |---|---|---|---|
266
+ | `id` (required) | `string` | — | The identity subtasks name in their `lane`. |
267
+ | `label` | `ReactNode` | — | What the lane header says - the machine or station. Real text, read by a screen reader; without one the header shows the id. |
268
+
269
+ #### Why it is like this
270
+
271
+ ##### A lane is not a row
272
+
273
+ A row is a place in a list; a lane is a **machine**. Whatever stands on it stands on it because the plant put it there, and two things on one lane at one time are not a layout problem - they are a finding a planner has to see. That single sentence decides most of what this component does and does not do.
274
+
275
+ It is why a lane keeps its height whatever lies on it, why nothing is ever moved to another lane to make room, and why the schedule has no notion of "fitting" work into the space available. A table may re-flow. A plan may not: the picture would stop being the plant.
276
+
277
+ ##### What that costs, said plainly
278
+
279
+ A busy lane gets crowded, and the schedule does not tidy it up for you. Three overlapping stops are drawn overlapping, offset by a few pixels each so that all three can be seen and hit, and marked. If you want them apart, you move them - and then the plan really has changed, which is the point.
280
+
281
+ ### Lane groups
282
+
283
+ Halls, lines and machine groups over the lanes - to any depth, and folded into one row when a planner wants the part they work on.
284
+
285
+ ```ts
286
+ import { LaneGroup, Lane } from "@umriss-ui/schedule";
287
+ ```
288
+
289
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/lane-groups
290
+
291
+ #### Examples
292
+
293
+ ##### A group of lanes
294
+
295
+ ```tsx
296
+ import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
297
+ import type { Subtask, Task } from "@umriss-ui/schedule";
298
+
299
+ /* `<LaneGroup>` puts structure over the lanes. It is not a lane itself:
300
+ nothing sits on it, no finding is reported for it, no intent names it. A
301
+ subtask still names a machine, and `canMoveTo` is still asked about a
302
+ machine - a group only says which machines belong together.
303
+
304
+ Declared by composition, so a group reads in JSX as it reads in the plant:
305
+ the lanes inside the tags are the lanes in the hall. A lane never names its
306
+ group; the group gives its id downwards, which is why moving a machine
307
+ between groups is moving a line of JSX.
308
+
309
+ An open group shows a slim head above its lanes, with its name and how many
310
+ lanes it holds. The chevron folds it - that is the next example.
311
+
312
+ The lanes keep the order they were DECLARED in, whatever group they are in.
313
+ That is the order a reader wrote and the only one they can predict. */
314
+
315
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
316
+ const min = (n: number) => n * 60_000;
317
+
318
+ const TASKS: Task[] = [
319
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
320
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
321
+ ];
322
+
323
+ const STEPS: Subtask[] = [
324
+ { id: "a-2041-1", task: "a-2041", lane: "press-1", from: at(7), to: at(9), setup: min(30) },
325
+ { id: "a-2043-1", task: "a-2043", lane: "press-2", from: at(8), to: at(10, 30), teardown: min(15) },
326
+ { id: "a-2041-2", task: "a-2041", lane: "weld", from: at(10), to: at(12) },
327
+ { id: "a-2043-2", task: "a-2043", lane: "paint", from: at(11, 30), to: at(14) },
328
+ ];
329
+
330
+ export default function AGroup() {
331
+ return (
332
+ <Schedule ariaLabel="Two presses in a hall, and two stations outside it" initialDomain={[at(6), at(15)]} height={240}>
333
+ <LaneGroup id="presses" label="Press shop">
334
+ <Lane id="press-1" label="Press 1" />
335
+ <Lane id="press-2" label="Press 2" />
336
+ </LaneGroup>
337
+ <Lane id="weld" label="Welding bay" />
338
+ <Lane id="paint" label="Paint shop" />
339
+ <Subtasks data={STEPS} tasks={TASKS} />
340
+ </Schedule>
341
+ );
342
+ }
343
+ ```
344
+
345
+ ##### Hall, line, machine
346
+
347
+ ```tsx
348
+ import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
349
+ import type { Subtask, Task } from "@umriss-ui/schedule";
350
+
351
+ /* Groups nest, to any depth, because plants do: a hall holds lines, a line
352
+ holds machines. Each level indents its header by one step, and each says how
353
+ many lanes lie under it - however deep.
354
+
355
+ Fold the inner line and the hall stays open around it. Fold the hall and the
356
+ line goes with it: what the reader sees is one row for the whole hall. The
357
+ inner fold is not forgotten while that happens - open the hall again and the
358
+ line is still folded, because folding the hall never touched the line's own
359
+ state. */
360
+
361
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
362
+
363
+ const TASKS: Task[] = [
364
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
365
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
366
+ ];
367
+
368
+ const STEPS: Subtask[] = [
369
+ { id: "s1", task: "a-2041", lane: "lathe-1", from: at(7), to: at(9) },
370
+ { id: "s2", task: "a-2043", lane: "lathe-2", from: at(8), to: at(10, 30) },
371
+ { id: "s3", task: "a-2041", lane: "mill", from: at(9, 30), to: at(11, 30) },
372
+ { id: "s4", task: "a-2043", lane: "press-1", from: at(11), to: at(13) },
373
+ { id: "s5", task: "a-2041", lane: "paint", from: at(12), to: at(14) },
374
+ ];
375
+
376
+ export default function Nesting() {
377
+ return (
378
+ <Schedule ariaLabel="A hall with a turning line and a press, and a paint shop outside it" initialDomain={[at(6), at(15)]} height={330} headerWidth={200}>
379
+ <LaneGroup id="hall-a" label="Hall A">
380
+ <LaneGroup id="turning" label="Turning line">
381
+ <Lane id="lathe-1" label="Lathe 1" />
382
+ <Lane id="lathe-2" label="Lathe 2" />
383
+ <Lane id="mill" label="Mill" />
384
+ </LaneGroup>
385
+ <Lane id="press-1" label="Press 1" />
386
+ </LaneGroup>
387
+ <Lane id="paint" label="Paint shop" />
388
+ <Subtasks data={STEPS} tasks={TASKS} />
389
+ </Schedule>
390
+ );
391
+ }
392
+ ```
393
+
394
+ ##### Which groups are folded, controlled
395
+
396
+ ```tsx
397
+ import { useState } from "react";
398
+ import { Button, Stack, Text } from "@umriss-ui/core";
399
+ import { Lane, LaneGroup, Schedule, Subtasks } from "@umriss-ui/schedule";
400
+ import type { Subtask, Task } from "@umriss-ui/schedule";
401
+
402
+ /* `collapsedGroups`, `defaultCollapsedGroups` and `onCollapsedGroupsChange`
403
+ are the shape `selectedTask` has: leave them out and the schedule keeps the
404
+ state itself; pass a list and the application owns it.
405
+
406
+ Owning it is worth something. The list can be stored with the planner's
407
+ other preferences, put in the address, or handed to a second schedule so two
408
+ views fold together. Here two buttons move it from outside, and the readout
409
+ is the application's own state.
410
+
411
+ It is NOT an intent. An intent asks to change the plan, and a caller that
412
+ applies every intent it receives must never find a fold among them: folding
413
+ says what is on screen and nothing about what the plant is doing. */
414
+
415
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
416
+
417
+ const TASKS: Task[] = [{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" }];
418
+
419
+ const STEPS: Subtask[] = [
420
+ { id: "s1", task: "a-2041", lane: "press-1", from: at(7), to: at(9) },
421
+ { id: "s2", task: "a-2041", lane: "press-2", from: at(9, 30), to: at(11) },
422
+ { id: "s3", task: "a-2041", lane: "weld-1", from: at(11, 30), to: at(13) },
423
+ { id: "s4", task: "a-2041", lane: "weld-2", from: at(13), to: at(14, 30) },
424
+ ];
425
+
426
+ const ALL = ["presses", "welding"];
427
+
428
+ export default function Controlled() {
429
+ const [folded, setFolded] = useState<readonly string[]>(["welding"]);
430
+
431
+ return (
432
+ <Stack gap={3}>
433
+ <Stack direction="row" gap={2}>
434
+ <Button size="sm" variant="secondary" onClick={() => setFolded(ALL)} data-fold-all>
435
+ Fold everything
436
+ </Button>
437
+ <Button size="sm" variant="secondary" onClick={() => setFolded([])} data-unfold-all>
438
+ Unfold everything
439
+ </Button>
440
+ </Stack>
441
+ <Schedule
442
+ ariaLabel="Two presses and two welding bays, folded from outside"
443
+ initialDomain={[at(6), at(15)]}
444
+ height={280}
445
+ collapsedGroups={folded}
446
+ onCollapsedGroupsChange={setFolded}
447
+ >
448
+ <LaneGroup id="presses" label="Press shop">
449
+ <Lane id="press-1" label="Press 1" />
450
+ <Lane id="press-2" label="Press 2" />
451
+ </LaneGroup>
452
+ <LaneGroup id="welding" label="Welding">
453
+ <Lane id="weld-1" label="Bay 1" />
454
+ <Lane id="weld-2" label="Bay 2" />
455
+ </LaneGroup>
456
+ <Subtasks data={STEPS} tasks={TASKS} />
457
+ </Schedule>
458
+ <Text size="sm" mono tone="secondary" data-folded>
459
+ {folded.length === 0 ? "nothing folded" : folded.join(", ")}
460
+ </Text>
461
+ </Stack>
462
+ );
463
+ }
464
+ ```
465
+
466
+ ##### What a folded group shows
467
+
468
+ ```tsx
469
+ import { useState } from "react";
470
+ import { Lane, LaneGroup, Schedule, Subtasks, Transports, applyIntent, findings } from "@umriss-ui/schedule";
471
+ import { Stack, Text } from "@umriss-ui/core";
472
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
473
+
474
+ /* A folded group is not a closed box. It shows a **miniature**: every lane in
475
+ it as a thin strip, at a smaller scale, with its work in the tasks' own
476
+ colours. The real work, smaller - not a summary, not a packing, not a
477
+ utilisation band.
478
+
479
+ Fold the hall and watch what survives. The transport from the saw still
480
+ arrives, at the strip of the machine it arrives at. The overlap on the mill
481
+ is still marked, on the strip AND on the row, because folding is a planner
482
+ tidying the view and must never be a planner hiding a finding. Hover a strip
483
+ and the tooltip names the stop; click it and its whole task is outlined
484
+ across the plan.
485
+
486
+ What a strip does NOT show is everything that needs room to be read: no
487
+ appearance, no label, no progress rail, and no grips - a bar three pixels
488
+ high is not something to stretch by three pixels. That is what unfolding is
489
+ for.
490
+
491
+ A drag held over the folded hall opens it for the gesture, so work can be
492
+ moved into a group without preparing the view first. It shuts again when the
493
+ drag ends - the application did not fold anything, so its own list is never
494
+ written to and it hears nothing.
495
+
496
+ `findings()` is the proof that nothing moved: the list below is computed
497
+ from the data and does not change by one entry when the hall folds. */
498
+
499
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
500
+ const min = (n: number) => n * 60_000;
501
+
502
+ const TASKS: Task[] = [
503
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
504
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
505
+ ];
506
+
507
+ const STEPS: Subtask[] = [
508
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6, 30), to: at(8), teardown: min(15) },
509
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(9), to: at(11, 30), setup: min(30) },
510
+ { id: "a-2043-1", task: "a-2043", lane: "lathe", from: at(7), to: at(9) },
511
+ /* Claims the mill while the housing still has it: an overlap, on purpose. */
512
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(11), to: at(13), setup: min(15) },
513
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(13, 30), to: at(15) },
514
+ ];
515
+
516
+ const MOVES: Transport[] = [
517
+ /* Out of the saw, which stands outside the hall, into a machine inside it. */
518
+ { id: "t-1", from: "a-2041-1", to: "a-2041-2", duration: min(20) },
519
+ { id: "t-2", from: "a-2043-1", to: "a-2043-2", duration: min(30) },
520
+ { id: "t-3", from: "a-2043-2", to: "a-2043-3", duration: min(20) },
521
+ ];
522
+
523
+ const FOUND = findings(STEPS, MOVES);
524
+
525
+ export default function TheMiniature() {
526
+ const [folded, setFolded] = useState<readonly string[]>(["hall"]);
527
+ const [work, setWork] = useState<readonly Subtask[]>(STEPS);
528
+ const [last, setLast] = useState("Hold a bar over the folded hall");
529
+
530
+ return (
531
+ <Stack gap={3}>
532
+ <Schedule
533
+ ariaLabel="A hall that folds into one row"
534
+ initialDomain={[at(6), at(16)]}
535
+ height={240}
536
+ collapsedGroups={folded}
537
+ onCollapsedGroupsChange={setFolded}
538
+ intents={["move", "lane"]}
539
+ onIntent={(intent) => {
540
+ if (intent.kind === "place") return;
541
+ setWork((current) => current.map((step) => applyIntent(step, intent)));
542
+ setLast(`${intent.subtask}: ${intent.kind}`);
543
+ }}
544
+ >
545
+ <Lane id="saw" label="Saw 1" />
546
+ <LaneGroup id="hall" label="Hall A">
547
+ <Lane id="lathe" label="Lathe 1" />
548
+ <Lane id="mill" label="Mill" />
549
+ </LaneGroup>
550
+ <Lane id="paint" label="Paint shop" />
551
+ <Transports data={MOVES} />
552
+ <Subtasks data={work} tasks={TASKS} />
553
+ </Schedule>
554
+ <Text size="sm" mono tone="secondary" data-last-move>
555
+ {last}
556
+ </Text>
557
+ <Text size="sm" mono tone="secondary" data-findings-count>
558
+ {FOUND.overlaps.length} overlap and {FOUND.lateTransports.length} late transport - folded or not
559
+ </Text>
560
+ </Stack>
561
+ );
562
+ }
563
+ ```
564
+
565
+ #### API
566
+
567
+ ##### `LaneGroupProps`
568
+
569
+ | Prop | Type | Default | Description |
570
+ |---|---|---|---|
571
+ | `id` (required) | `string` | — | The group's identity - what `collapsedGroups` names. It is never a lane: no subtask sits on it, no finding is reported for it, and no intent names it (ADR-0025). |
572
+ | `label` | `ReactNode` | — | What the group's header says - the hall, the line, the machine group. Real text, read by a screen reader; without one the header shows the id. |
573
+ | `children` | `ReactNode` | — | `Lane`s and further `LaneGroup`s, to any depth. |
574
+
575
+ #### Why it is like this
576
+
577
+ ##### A folded group keeps every bar its place
578
+
579
+ Folding is how a planner who works on the presses stops scrolling past the rest of the plant. It must not also be how they stop seeing it. So a folded group is not a closed box: it is a **miniature** - every lane in it as a thin strip, at a smaller scale, with its work in the tasks' own colours. The real work, smaller.
580
+
581
+ Everything that was true of a bar stays true of its strip. A transport arrives at the strip of the machine it arrives at, not at the top of the group. An overlap is marked on the strip *and* on the row, because a three-pixel strip is not where an alarm can live alone. Hover names the stop; a click takes its whole task across the plan. And `findings()` returns the same list folded and unfolded, because it is computed from the data and folding is not data.
582
+
583
+ ##### A change of scale, not an arrangement
584
+
585
+ This is where the component's two standing sentences would be easiest to break, and they are not broken. **A lane is not a row**: a strip is still that machine, and every real lane still resolves to exactly one place. **An overlap is never packed into sub-lanes**: nothing is moved to make anything fit - every bar keeps the time it has and the strip its lane has, smaller.
586
+
587
+ ##### What it costs, said plainly
588
+
589
+ A strip shows what needs no room: where the work is and whose it is. It shows no appearance, no bar label and no progress rail, and it offers no grips - a bar three pixels high is not something to stretch by three pixels. That is what unfolding is for, and it is one click or one keystroke away.
590
+
591
+ Nor is there a summary of any kind on a folded group - no band of how busy it is, no bar spanning its work. A summary is a claim the schedule computes, and once both are bars a planner cannot tell a computed claim from a drawn fact. See `docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md`.
592
+
593
+ ### Time axis and calendar
594
+
595
+ The two bands, and the operating calendar that cuts the hours the plant does not run out of the axis.
596
+
597
+ ```ts
598
+ import { Schedule } from "@umriss-ui/schedule";
599
+ ```
600
+
601
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/time-axis
602
+
603
+ #### Examples
604
+
605
+ ##### Nights cut out of the axis
606
+
607
+ ```tsx
608
+ import { useState } from "react";
609
+ import { Lane, Schedule, Subtasks, Transports, applyIntent } from "@umriss-ui/schedule";
610
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
611
+
612
+ /* The plant runs two shifts, 06:00 to 22:00. An operating calendar lists the
613
+ intervals in which time counts, and the axis leaves the rest out: three days
614
+ in the width of two, and no empty night between the evening's work and the
615
+ morning's. A dotted line marks each seam, because an axis that takes time
616
+ out and does not say so claims a continuity it does not have.
617
+
618
+ The calendar is a list of intervals and nothing more. Deriving it from a
619
+ shift plan with its holidays is the application's business - the schedule
620
+ draws what the list says.
621
+
622
+ Editing follows the calendar: drag the casting into the night and the ghost
623
+ stops at the seam where time counts again, so an intent never asks for a
624
+ time the plant does not run. */
625
+
626
+ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, 16 + day, hours, minutes).getTime();
627
+
628
+ const SHIFTS = [0, 1, 2].map((day) => ({ from: at(day, 6), to: at(day, 22) }));
629
+
630
+ const TASKS: Task[] = [{ id: "casting", color: "light-dark(#c2410c, #f08a52)" }];
631
+
632
+ const STEPS: Subtask[] = [
633
+ { id: "pour", task: "casting", lane: "foundry", from: at(0, 18), to: at(0, 21, 30), setup: 30 * 60_000 },
634
+ { id: "fettle", task: "casting", lane: "fettling", from: at(1, 6, 30), to: at(1, 11), setup: 15 * 60_000 },
635
+ { id: "machine", task: "casting", lane: "machining", from: at(1, 20), to: at(2, 9), teardown: 30 * 60_000 },
636
+ ];
637
+
638
+ const MOVES: Transport[] = [
639
+ { id: "cool", from: "pour", to: "fettle", duration: 20 * 60_000 },
640
+ { id: "carry", from: "fettle", to: "machine", duration: 45 * 60_000 },
641
+ ];
642
+
643
+ export default function OperatingCalendar() {
644
+ const [steps, setSteps] = useState<readonly Subtask[]>(STEPS);
645
+ return (
646
+ <Schedule
647
+ ariaLabel="Three days of casting, nights removed"
648
+ initialDomain={[at(0, 6), at(2, 22)]}
649
+ calendar={SHIFTS}
650
+ height={220}
651
+ intents={["move"]}
652
+ onIntent={(intent) => setSteps((current) => current.map((step) => applyIntent(step, intent)))}
653
+ >
654
+ <Lane id="foundry" label="Foundry" />
655
+ <Lane id="fettling" label="Fettling" />
656
+ <Lane id="machining" label="Machining" />
657
+ <Transports data={MOVES} />
658
+ <Subtasks data={steps} tasks={TASKS} />
659
+ </Schedule>
660
+ );
661
+ }
662
+ ```
663
+
664
+ ### Pan and zoom
665
+
666
+ Dragging the background, the wheel, Ctrl and a pinch: what moves the view, and what the view never changes.
667
+
668
+ ```ts
669
+ import { Schedule } from "@umriss-ui/schedule";
670
+ ```
671
+
672
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/pan-and-zoom
673
+
674
+ #### Examples
675
+
676
+ ##### Moving through the plan
677
+
678
+ ```tsx
679
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
680
+ import type { Subtask, Task } from "@umriss-ui/schedule";
681
+
682
+ /* Nothing here is configured. A schedule pans and zooms because that is what a
683
+ plan is for, and the gestures are the ones every scrolling surface has:
684
+
685
+ drag the background pans through time AND through the lanes
686
+ the wheel scrolls the lanes, and releases the page at the end
687
+ Shift with the wheel pans through time
688
+ Ctrl or Cmd with it zooms; a trackpad pinch arrives the same way
689
+ two fingers zoom
690
+
691
+ The wheel releasing the page is the part worth saying out loud: a plan
692
+ halfway down a page must not swallow the scroll that was meant for the page.
693
+ Once the lanes are at their end, the wheel goes back to the document.
694
+
695
+ What moves is the VIEW and only the view. No data changes, nothing is
696
+ reported, no intent is raised: pan and zoom exchange the visible span and
697
+ the scroll, and nothing else (ADR-0001). `zoomLimits` says how far the span
698
+ may be taken - an hour to twenty-eight days, unless a caller says otherwise;
699
+ this one is held between two hours and two days, so the ends are reachable
700
+ in a few turns of the wheel. */
701
+
702
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
703
+ const min = (n: number) => n * 60_000;
704
+ const HOUR = 60 * 60_000;
705
+
706
+ const TASKS: Task[] = [
707
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
708
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
709
+ ];
710
+
711
+ const STATIONS = [
712
+ { id: "saw", label: "Saw 1" },
713
+ { id: "mill", label: "Mill" },
714
+ { id: "press", label: "Press 2" },
715
+ ];
716
+
717
+ const STEPS: Subtask[] = [
718
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
719
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
720
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
721
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(12), to: at(13, 30), setup: min(15) },
722
+ ];
723
+
724
+ export default function PanAndZoom() {
725
+ return (
726
+ <Schedule
727
+ ariaLabel="A plan to pan and zoom"
728
+ initialDomain={[at(5, 30), at(18)]}
729
+ height={196}
730
+ zoomLimits={{ min: 2 * HOUR, max: 48 * HOUR }}
731
+ >
732
+ {STATIONS.map((station) => (
733
+ <Lane key={station.id} id={station.id} label={station.label} />
734
+ ))}
735
+ <Subtasks data={STEPS} tasks={TASKS} />
736
+ </Schedule>
737
+ );
738
+ }
739
+ ```
740
+
741
+ ### Now line
742
+
743
+ A line across the lanes at the present moment, following the clock or fixed where a replay needs it.
744
+
745
+ ```ts
746
+ import { Schedule } from "@umriss-ui/schedule";
747
+ ```
748
+
749
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/now-line
750
+
751
+ #### Examples
752
+
753
+ ##### Where the present stands
754
+
755
+ ```tsx
756
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
757
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
758
+
759
+ /* `now` draws a line across the lanes at the present moment, and a mark where
760
+ it meets the time band. Everything to its left should have happened; what
761
+ still stands there in full colour is late, and a planner sees it at once.
762
+
763
+ In an application you write `now` and nothing else: it reads the clock and
764
+ moves on by the minute, so a schedule left open on a wall screen stays true.
765
+ This plan is a fixed Tuesday in March, and the real clock would put the line
766
+ outside it - where it would be as useless as the plan is old. An instant
767
+ instead of `true` fixes the line where it belongs, which is also what a
768
+ replay of a past shift needs, and what makes a picture of it hold still. */
769
+
770
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
771
+ const min = (n: number) => n * 60_000;
772
+
773
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
774
+
775
+
776
+ const STATIONS = [
777
+ { id: "saw", label: "Saw 1" },
778
+ { id: "mill", label: "Mill" },
779
+ { id: "press", label: "Press 2" },
780
+ ];
781
+
782
+ const ORDERS: readonly Task[] = [
783
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
784
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
785
+ ];
786
+
787
+ const STEPS: readonly Subtask[] = [
788
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
789
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
790
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
791
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
792
+ ];
793
+
794
+ const MOVES: readonly Transport[] = [
795
+ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
796
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
797
+ ];
798
+
799
+ const HALF_PAST_TEN = new Date(2026, 2, 17, 10, 30).getTime();
800
+
801
+ export default function NowLine() {
802
+ return (
803
+ <Schedule
804
+ ariaLabel="Plan of Tuesday, 17 March, with the present"
805
+ initialDomain={DAY_OF_PLAN}
806
+ height={196}
807
+ now={HALF_PAST_TEN}
808
+ >
809
+ {STATIONS.map((station) => (
810
+ <Lane key={station.id} id={station.id} label={station.label} />
811
+ ))}
812
+ <Transports data={MOVES} />
813
+ <Subtasks data={STEPS} tasks={ORDERS} />
814
+ </Schedule>
815
+ );
816
+ }
817
+ ```
818
+
819
+ ### Subtasks
820
+
821
+ The drawn intervals: a main time with its setup and teardown, in the colour of the task they belong to.
822
+
823
+ ```ts
824
+ import { Subtasks } from "@umriss-ui/schedule";
825
+ ```
826
+
827
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/subtasks
828
+
829
+ #### Examples
830
+
831
+ ##### Main time, setup and teardown
832
+
833
+ ```tsx
834
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
835
+ import type { Subtask, Task } from "@umriss-ui/schedule";
836
+
837
+ /* A subtask is a main time with an optional setup before it and a teardown
838
+ after it. The main time is drawn full, setup and teardown faint in the same
839
+ colour with an edge: preparation reads as belonging to the work, and as not
840
+ being it.
841
+
842
+ Both are durations beside the main time, not times of their own - moving
843
+ the subtask takes them along, and each is changed on its own (see Intent). */
844
+
845
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
846
+ const min = (n: number) => n * 60_000;
847
+
848
+ const TASKS: Task[] = [{ id: "housing", color: "light-dark(#2563eb, #6b9bff)" }];
849
+
850
+ const WORK: Subtask[] = [
851
+ { id: "plain", task: "housing", lane: "mill", from: at(6, 30), to: at(8) },
852
+ { id: "with-setup", task: "housing", lane: "mill", from: at(9), to: at(10, 30), setup: min(30) },
853
+ { id: "with-both", task: "housing", lane: "mill", from: at(11, 30), to: at(13), setup: min(20), teardown: min(40) },
854
+ ];
855
+
856
+ export default function SetupAndTeardown() {
857
+ return (
858
+ <Schedule ariaLabel="Three subtasks on the mill" initialDomain={[at(6), at(14, 30)]} height={110}>
859
+ <Lane id="mill" label="Mill" />
860
+ <Subtasks data={WORK} tasks={TASKS} />
861
+ </Schedule>
862
+ );
863
+ }
864
+ ```
865
+
866
+ ##### Colour is the task's
867
+
868
+ ```tsx
869
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
870
+ import type { Subtask, Task } from "@umriss-ui/schedule";
871
+
872
+ /* Every subtask of a task shares one colour, and the colour is the caller's:
873
+ any CSS colour, a token of the application, a `light-dark()` pair. Here it
874
+ means the order's priority rather than the order itself - two rush orders in
875
+ the danger colour, the rest in the accent.
876
+
877
+ A subtask whose task is not in `tasks` is drawn muted: it is on the plan, and
878
+ nobody said what it is. */
879
+
880
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
881
+
882
+ const TASKS: Task[] = [
883
+ { id: "rush-1", color: "var(--u-color-danger)" },
884
+ { id: "rush-2", color: "var(--u-color-danger)" },
885
+ { id: "normal", color: "var(--u-color-accent)" },
886
+ ];
887
+
888
+ const WORK: Subtask[] = [
889
+ { id: "r1-a", task: "rush-1", lane: "saw", from: at(6), to: at(7) },
890
+ { id: "r1-b", task: "rush-1", lane: "lathe", from: at(7, 30), to: at(9) },
891
+ { id: "r2-a", task: "rush-2", lane: "saw", from: at(7, 15), to: at(8, 15) },
892
+ { id: "n-a", task: "normal", lane: "lathe", from: at(9, 30), to: at(11) },
893
+ { id: "unknown", task: "not-listed", lane: "saw", from: at(9), to: at(10, 30) },
894
+ ];
895
+
896
+ export default function TaskColours() {
897
+ return (
898
+ <Schedule ariaLabel="Rush orders among the rest" initialDomain={[at(5, 30), at(11, 30)]} height={150}>
899
+ <Lane id="saw" label="Saw" />
900
+ <Lane id="lathe" label="Lathe" />
901
+ <Subtasks data={WORK} tasks={TASKS} />
902
+ </Schedule>
903
+ );
904
+ }
905
+ ```
906
+
907
+ #### API
908
+
909
+ ##### `SubtasksProps`
910
+
911
+ | Prop | Type | Default | Description |
912
+ |---|---|---|---|
913
+ | `data` (required) | `readonly Subtask[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
914
+ | `tasks` (required) | `readonly 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. |
915
+
916
+ ##### `Subtask`
917
+
918
+ | Prop | Type | Default | Description |
919
+ |---|---|---|---|
920
+ | `id` (required) | `string` | — | The caller's identity of the subtask. |
921
+ | `task` (required) | `string` | — | The id of the task it belongs to. |
922
+ | `lane` (required) | `string` | — | The id of the lane it sits on. |
923
+ | `from` (required) | `number` | — | Start of the main time. |
924
+ | `to` (required) | `number` | — | End of the main time. |
925
+ | `setup` | `number` | — | Duration of the setup before the main time; none is zero. |
926
+ | `teardown` | `number` | — | Duration of the teardown after the main time; none is zero. |
927
+ | `name` | `string` | — | What the subtask is called, where the schedule names it. |
928
+ | `appearance` | `readonly 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. |
929
+ | `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. |
930
+
931
+ ##### `Task`
932
+
933
+ | Prop | Type | Default | Description |
934
+ |---|---|---|---|
935
+ | `id` (required) | `string` | — | The caller's identity of the task. |
936
+ | `color` (required) | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
937
+ | `name` | `string` | — | What the task is called, where the schedule names it. |
938
+
939
+ ### Bar labels
940
+
941
+ What stands written in a bar - cut off where it must be, left out where it would say nothing, held at the view's edge.
942
+
943
+ ```ts
944
+ import { Schedule } from "@umriss-ui/schedule";
945
+ ```
946
+
947
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/bar-labels
948
+
949
+ #### Examples
950
+
951
+ ##### What a bar says
952
+
953
+ ```tsx
954
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
955
+ import type { Subtask, Task } from "@umriss-ui/schedule";
956
+
957
+ /* `label` writes a line into every bar: the order, the article, the quantity -
958
+ whatever a planner reads first. The text lies on the main time, because the
959
+ setup is not the work.
960
+
961
+ It is cut off with an ellipsis where the bar is too narrow, and left out
962
+ where even a word and an ellipsis would say nothing - the inspections at the
963
+ bottom are minutes long, and a row of dots in them would be worse than
964
+ nothing. A bar that begins before the view keeps its text at the edge, the
965
+ way the day band keeps its date: pan into the afternoon and watch the labels
966
+ stay.
967
+
968
+ The text's colour follows the bar's: the schedule reads the brightness of
969
+ the colour the caller gave the task and puts light text on a dark bar and
970
+ dark text on a pale one. */
971
+
972
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
973
+ const min = (n: number) => n * 60_000;
974
+
975
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
976
+
977
+
978
+ const STATIONS = [
979
+ { id: "mill", label: "Mill" },
980
+ { id: "press", label: "Press 2" },
981
+ { id: "qa", label: "Inspection" },
982
+ ];
983
+
984
+ const ORDERS: readonly Task[] = [
985
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
986
+ { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
987
+ { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
988
+ ];
989
+
990
+ const STEPS: readonly Subtask[] = [
991
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
992
+ { id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
993
+ { id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
994
+ { id: "a-2046-3", task: "a-2046", lane: "qa", from: at(15), to: at(15, 30) },
995
+ ];
996
+
997
+ const ORDER_NAMES = new Map(ORDERS.map((order) => [order.id, order.name ?? order.id]));
998
+
999
+ export default function BarLabels() {
1000
+ return (
1001
+ <Schedule
1002
+ ariaLabel="Plan of Tuesday, 17 March, with the orders written in"
1003
+ initialDomain={DAY_OF_PLAN}
1004
+ height={196}
1005
+ label={(subtask) => ORDER_NAMES.get(subtask.task) ?? subtask.task}
1006
+ >
1007
+ {STATIONS.map((station) => (
1008
+ <Lane key={station.id} id={station.id} label={station.label} />
1009
+ ))}
1010
+ <Subtasks data={STEPS} tasks={ORDERS} />
1011
+ </Schedule>
1012
+ );
1013
+ }
1014
+ ```
1015
+
1016
+ ### Appearances
1017
+
1018
+ What a bar says besides its colour: provisional, fixed, another shift's, running on - one channel of the drawing each.
1019
+
1020
+ ```ts
1021
+ import { Subtasks, resolveAppearance } from "@umriss-ui/schedule";
1022
+ ```
1023
+
1024
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/appearances
1025
+
1026
+ #### Examples
1027
+
1028
+ ##### Provisional: planned, not released
1029
+
1030
+ ```tsx
1031
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1032
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1033
+
1034
+ /* `"provisional"` owns the FILL, and has none: the surface shows straight
1035
+ through, with a dashed outline in the task's colour at full weight.
1036
+
1037
+ Empty, and not pale. A pale fill of the task colour already means something
1038
+ in this picture - a setup or a teardown - and the released bar above carries
1039
+ both, so the two statements stand side by side and cannot be mistaken for
1040
+ one another. That was worth a channel of its own: a planner who has to ask
1041
+ whether a faint end is preparation or a draft has lost the glance the plan
1042
+ was drawn for. */
1043
+
1044
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1045
+ const min = (n: number) => n * 60_000;
1046
+
1047
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
1048
+
1049
+ const WORK: Subtask[] = [
1050
+ { id: "released", task: "order", lane: "released", from: at(7), to: at(10), setup: min(30), teardown: min(30) },
1051
+ { id: "draft", task: "order", lane: "draft", from: at(7), to: at(10), appearance: ["provisional"] },
1052
+ ];
1053
+
1054
+ export default function Provisional() {
1055
+ return (
1056
+ <Schedule ariaLabel="Released work above, provisional work below" initialDomain={[at(6, 30), at(11)]} height={144}>
1057
+ <Lane id="released" label="Released, with setup" />
1058
+ <Lane id="draft" label="Provisional" />
1059
+ <Subtasks data={WORK} tasks={TASKS} />
1060
+ </Schedule>
1061
+ );
1062
+ }
1063
+ ```
1064
+
1065
+ ##### Fixed: work that may not move
1066
+
1067
+ ```tsx
1068
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1069
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1070
+
1071
+ /* `"fixed"` owns the ENDS: a cap at each end of the main time, three pixels
1072
+ wide, in the colour the bar's own label takes, set just inside the end so
1073
+ that the bar's colour frames it.
1074
+
1075
+ At its ends and not across its face, for two reasons. The statement is about
1076
+ where the work begins and ends - that is where it is nailed down. And the
1077
+ face belongs to the label: a pattern over the whole bar is the loudest mark
1078
+ in the picture for the calmest thing it has to say, and it lies under the
1079
+ word a planner came to read. */
1080
+
1081
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1082
+
1083
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
1084
+
1085
+ const WORK: Subtask[] = [
1086
+ { id: "movable", task: "order", lane: "movable", from: at(7), to: at(10) },
1087
+ { id: "nailed", task: "order", lane: "nailed", from: at(7), to: at(10), appearance: ["fixed"] },
1088
+ ];
1089
+
1090
+ export default function Fixed() {
1091
+ return (
1092
+ <Schedule
1093
+ ariaLabel="Work that may move above, fixed work below"
1094
+ initialDomain={[at(6, 30), at(11)]}
1095
+ height={144}
1096
+ label={(subtask) => (subtask.id === "nailed" ? "A-2041 · fixed" : "A-2041")}
1097
+ >
1098
+ <Lane id="movable" label="May move" />
1099
+ <Lane id="nailed" label="Fixed" />
1100
+ <Subtasks data={WORK} tasks={TASKS} />
1101
+ </Schedule>
1102
+ );
1103
+ }
1104
+ ```
1105
+
1106
+ ##### Muted: another shift's work
1107
+
1108
+ ```tsx
1109
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1110
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1111
+
1112
+ /* `"muted"` owns the SATURATION: the task's colour mixed half into the
1113
+ surface, at full height, opaque, and with no outline.
1114
+
1115
+ Full height, because a bar drawn half as high reads as a different KIND of
1116
+ thing, and loses its label with its room. Opaque and unoutlined, because
1117
+ transparency and an edge are what a setup is made of - and this example puts
1118
+ a setup right beside it so the difference has to hold. Another crew's plan
1119
+ should step back; it should not turn into preparation on the way. */
1120
+
1121
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1122
+ const min = (n: number) => n * 60_000;
1123
+
1124
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
1125
+
1126
+ const WORK: Subtask[] = [
1127
+ { id: "ours", task: "order", lane: "ours", from: at(7), to: at(10), setup: min(30) },
1128
+ { id: "theirs", task: "order", lane: "theirs", from: at(7), to: at(10), setup: min(30), appearance: ["muted"] },
1129
+ ];
1130
+
1131
+ export default function Muted() {
1132
+ return (
1133
+ <Schedule
1134
+ ariaLabel="This shift's work above, another shift's below"
1135
+ initialDomain={[at(6, 30), at(11)]}
1136
+ height={144}
1137
+ label={() => "A-2041 Housing"}
1138
+ >
1139
+ <Lane id="ours" label="This shift" />
1140
+ <Lane id="theirs" label="Another shift" />
1141
+ <Subtasks data={WORK} tasks={TASKS} />
1142
+ </Schedule>
1143
+ );
1144
+ }
1145
+ ```
1146
+
1147
+ ##### Progress: how far the work has got
1148
+
1149
+ ```tsx
1150
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1151
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1152
+
1153
+ /* `progress` is not an appearance but a share from 0 to 1, and it owns a RAIL:
1154
+ a band inside the main time, set in from the bar's lower edge so that it is
1155
+ a mark ON the bar and not the bar's own edge.
1156
+
1157
+ Inside the main time, and stopping where it stops: progress measures the
1158
+ WORK, and a rail running on under the teardown would be measuring the
1159
+ clearing away as well. The bar below carries a teardown, so that can be
1160
+ seen.
1161
+
1162
+ A rail rather than a paler remainder, for the reason every appearance here
1163
+ has: a paler part of a bar is preparation in this picture.
1164
+
1165
+ Leaving `progress` out claims nothing at all. A bar at zero per cent and a
1166
+ bar that says nothing about progress are two different statements, and the
1167
+ top lane is the second of them. */
1168
+
1169
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1170
+ const min = (n: number) => n * 60_000;
1171
+
1172
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
1173
+
1174
+ const WORK: Subtask[] = [
1175
+ { id: "unclaimed", task: "order", lane: "unclaimed", from: at(7), to: at(10) },
1176
+ { id: "started", task: "order", lane: "started", from: at(7), to: at(10), progress: 0.65, teardown: min(30) },
1177
+ ];
1178
+
1179
+ export default function Progress() {
1180
+ return (
1181
+ <Schedule ariaLabel="Work claiming nothing above, work at 65 per cent below" initialDomain={[at(6, 30), at(11)]} height={144}>
1182
+ <Lane id="unclaimed" label="Says nothing" />
1183
+ <Lane id="started" label="65 per cent done" />
1184
+ <Subtasks data={WORK} tasks={TASKS} />
1185
+ </Schedule>
1186
+ );
1187
+ }
1188
+ ```
1189
+
1190
+ ##### Open: work that goes on past the view
1191
+
1192
+ ```tsx
1193
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1194
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1195
+
1196
+ /* `"open"` owns the FADE: where the bar passes the edge of the view it fades
1197
+ into the surface instead of ending in an edge a reader would take for its
1198
+ end.
1199
+
1200
+ At WHICHEVER edge it passes, left as well as right. An edge of the screen is
1201
+ an edge of the screen on both sides, and a bar that began before the view
1202
+ used to say nothing at all about it - the bottom lane is that case.
1203
+
1204
+ The middle lane passes neither edge, and carries no fade at all. That is not
1205
+ an omission: the fade says "the edge of the screen is not the end of this
1206
+ work", and a bar whose ends are both in view has no such edge to speak
1207
+ about. Zoom out until it does and the fade appears. */
1208
+
1209
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1210
+
1211
+ const TASKS: Task[] = [{ id: "order", color: "light-dark(#2563eb, #6b9bff)" }];
1212
+
1213
+ const WORK: Subtask[] = [
1214
+ { id: "runs-on", task: "order", lane: "runs-on", from: at(7), to: at(13), appearance: ["open"] },
1215
+ /* Wholly in view: both its ends are there to be seen, so there is no edge
1216
+ of the screen to say anything about. */
1217
+ { id: "whole", task: "order", lane: "whole", from: at(7), to: at(10), appearance: ["open"] },
1218
+ { id: "began", task: "order", lane: "began", from: at(4), to: at(10), appearance: ["open"] },
1219
+ ];
1220
+
1221
+ export default function Open() {
1222
+ return (
1223
+ <Schedule ariaLabel="Work running past the view at either edge" initialDomain={[at(6, 30), at(11)]} height={188}>
1224
+ <Lane id="runs-on" label="Runs on" />
1225
+ <Lane id="whole" label="Wholly in view" />
1226
+ <Lane id="began" label="Began before" />
1227
+ <Subtasks data={WORK} tasks={TASKS} />
1228
+ </Schedule>
1229
+ );
1230
+ }
1231
+ ```
1232
+
1233
+ ##### Several statements on one bar
1234
+
1235
+ ```tsx
1236
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1237
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1238
+
1239
+ /* A bar rarely has only one thing to say. Another shift's work may be frozen
1240
+ and may run past the view, all at once - and it must then still read as
1241
+ three statements and not as a smear.
1242
+
1243
+ It can, because each of them owns a different property of the drawing. The
1244
+ saturation is muted, the ends are fixed, the edge of the view is open:
1245
+ nothing is painted twice over, so nothing cancels anything out. That is the
1246
+ whole reason the list is built that way, and this is the picture that proves
1247
+ it.
1248
+
1249
+ Two of them are settled before the drawing begins. `"provisional"` and
1250
+ `"fixed"` contradict - work cannot be planned and frozen at once - so the
1251
+ later one in the list wins, and the bar shows exactly one of the two. The
1252
+ rule is `resolveAppearance`; it is the caller's to read as well.
1253
+
1254
+ One combination has a visible order of its own: a FIXED bar that runs past
1255
+ the view loses its cap at that edge. The fade is painted after the caps
1256
+ because that is the truth of it - there is no end there to mark. The cap it
1257
+ still carries is the end that really is in view.
1258
+
1259
+ The labels are here for one more reason. A bar's label takes whatever colour
1260
+ reads on the bar, and a hollow bar is the surface - so provisional work is
1261
+ labelled in the ordinary text colour, and a muted bar in whatever reads on
1262
+ its mix. No bar loses its label to an appearance any more. */
1263
+
1264
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1265
+
1266
+ const TASKS: Task[] = [
1267
+ { id: "own", color: "light-dark(#2563eb, #6b9bff)" },
1268
+ { id: "other", color: "light-dark(#c2410c, #f08a52)" },
1269
+ ];
1270
+
1271
+ const WORK: Subtask[] = [
1272
+ /* Three statements, three channels: paler colour, capped ends, faded edge. */
1273
+ {
1274
+ id: "all-three",
1275
+ task: "other",
1276
+ lane: "all-three",
1277
+ from: at(7),
1278
+ to: at(13),
1279
+ appearance: ["fixed", "muted", "open"],
1280
+ setup: 30 * 60_000,
1281
+ },
1282
+ /* Provisional work that has got somewhere: the rail lies on the surface the
1283
+ hollow bar leaves, in the colour text takes there. */
1284
+ { id: "planned", task: "own", lane: "planned", from: at(7), to: at(10), appearance: ["provisional"], progress: 0.4 },
1285
+ /* Both, in this order: `"fixed"` is the later word, so this bar is fixed. */
1286
+ { id: "settled", task: "own", lane: "settled", from: at(7), to: at(10), appearance: ["provisional", "fixed"] },
1287
+ /* And the other way round, which makes it provisional. */
1288
+ { id: "reopened", task: "own", lane: "reopened", from: at(7), to: at(10), appearance: ["fixed", "provisional"] },
1289
+ ];
1290
+
1291
+ /** What stands in each bar. */
1292
+ const NAMES: Record<string, string> = {
1293
+ "all-three": "A-2043 · night shift",
1294
+ planned: "A-2041 · draft",
1295
+ settled: "A-2041 · released",
1296
+ reopened: "A-2041 · reopened",
1297
+ };
1298
+
1299
+ const LANES = [
1300
+ { id: "all-three", label: "Another shift, fixed, runs on" },
1301
+ { id: "planned", label: "Provisional, 40 per cent" },
1302
+ { id: "settled", label: "provisional then fixed" },
1303
+ { id: "reopened", label: "fixed then provisional" },
1304
+ ];
1305
+
1306
+ export default function Combinations() {
1307
+ return (
1308
+ <Schedule
1309
+ ariaLabel="Bars carrying several appearances at once"
1310
+ initialDomain={[at(6, 30), at(11)]}
1311
+ height={230}
1312
+ label={(subtask) => NAMES[subtask.id] ?? subtask.id}
1313
+ >
1314
+ {LANES.map((lane) => (
1315
+ <Lane key={lane.id} id={lane.id} label={lane.label} />
1316
+ ))}
1317
+ <Subtasks data={WORK} tasks={TASKS} />
1318
+ </Schedule>
1319
+ );
1320
+ }
1321
+ ```
1322
+
1323
+ ### Overlap
1324
+
1325
+ Two subtasks claiming one lane at one time: offset, marked, and never packed into sub-lanes.
1326
+
1327
+ ```ts
1328
+ import { Subtasks } from "@umriss-ui/schedule";
1329
+ ```
1330
+
1331
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/overlap
1332
+
1333
+ #### Examples
1334
+
1335
+ ##### An overlap is a finding
1336
+
1337
+ ```tsx
1338
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
1339
+ import type { Subtask, Task } from "@umriss-ui/schedule";
1340
+
1341
+ /* Two subtasks that claim the same lane at the same time are both drawn: the
1342
+ later one offset a few pixels and edged, the time they share marked across
1343
+ the lane. They are never packed into sub-lanes - packing would turn the
1344
+ double booking into layout and hide it.
1345
+
1346
+ Setup and teardown occupy the lane: the second pair below overlaps only
1347
+ where one's teardown meets the other's setup, and that is an overlap too. */
1348
+
1349
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1350
+ const min = (n: number) => n * 60_000;
1351
+
1352
+ const TASKS: Task[] = [
1353
+ { id: "first", color: "light-dark(#0d9488, #3cc7b8)" },
1354
+ { id: "second", color: "light-dark(#be185d, #f06aa6)" },
1355
+ ];
1356
+
1357
+ const WORK: Subtask[] = [
1358
+ { id: "a", task: "first", lane: "press-1", from: at(7), to: at(9, 30) },
1359
+ { id: "b", task: "second", lane: "press-1", from: at(8, 30), to: at(10, 30) },
1360
+ { id: "c", task: "first", lane: "press-2", from: at(7), to: at(8, 30), teardown: min(30) },
1361
+ { id: "d", task: "second", lane: "press-2", from: at(9, 15), to: at(10, 45), setup: min(30) },
1362
+ ];
1363
+
1364
+ export default function Overlap() {
1365
+ return (
1366
+ <Schedule ariaLabel="Two overlaps on two presses" initialDomain={[at(6, 30), at(11, 30)]} height={150}>
1367
+ <Lane id="press-1" label="Press 1" />
1368
+ <Lane id="press-2" label="Press 2" />
1369
+ <Subtasks data={WORK} tasks={TASKS} />
1370
+ </Schedule>
1371
+ );
1372
+ }
1373
+ ```
1374
+
1375
+ #### Why it is like this
1376
+
1377
+ ##### An overlap is never packed into sub-lanes
1378
+
1379
+ Every calendar you have used solves an overlap by splitting the column: two appointments at ten o'clock become two half-width boxes side by side. That is right for a calendar, where a column is a *day* and both things can happen. It is wrong for a plan, where a lane is a **machine** and both things cannot.
1380
+
1381
+ Packing would make the picture tidy and the plan a lie: a planner would see two boxes that fit and read them as work that fits. So the bars stay where their times put them, each offset a few pixels below the last so that all of them can be seen and hit, and the shared time is marked underneath. The tidiness is given up on purpose.
1382
+
1383
+ ##### The offset has a floor and a ceiling
1384
+
1385
+ Three levels of offset, and no more. A bar is made short enough that the deepest one still lies inside its lane, so an overlap never leaks into the machine below - which would be the same lie in a different direction.
1386
+
1387
+ ### Transports
1388
+
1389
+ A task's moves between its subtasks, from an end to a start - with what they connect and where their lines touch.
1390
+
1391
+ ```ts
1392
+ import { Transports } from "@umriss-ui/schedule";
1393
+ ```
1394
+
1395
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/transports
1396
+
1397
+ #### Examples
1398
+
1399
+ ##### Where a transport anchors
1400
+
1401
+ ```tsx
1402
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1403
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
1404
+
1405
+ /* A transport runs from one subtask's end to the next one's start - there is
1406
+ no other kind. Where exactly is declared per transport: `leaves` at the end of
1407
+ the main time or after the teardown, `arrives` at the start of the main time
1408
+ or before the setup. The default is the outer pair: the part leaves once the
1409
+ machine is cleared and must be there before the next one is set up.
1410
+
1411
+ The anchor is what the finding is judged by, so it should say what the
1412
+ transport really connects. Above the default, below a transport that leaves
1413
+ straight from the main time and arrives at it. */
1414
+
1415
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1416
+ const min = (n: number) => n * 60_000;
1417
+
1418
+ const TASKS: Task[] = [
1419
+ { id: "outer", color: "light-dark(#2563eb, #6b9bff)" },
1420
+ { id: "inner", color: "light-dark(#4d7c0f, #8fc43e)" },
1421
+ ];
1422
+
1423
+ const WORK: Subtask[] = [
1424
+ { id: "o-1", task: "outer", lane: "lathe", from: at(7), to: at(8, 30), teardown: min(30) },
1425
+ { id: "o-2", task: "outer", lane: "mill", from: at(10, 30), to: at(12), setup: min(30) },
1426
+ { id: "i-1", task: "inner", lane: "lathe", from: at(12), to: at(13, 30), teardown: min(30) },
1427
+ { id: "i-2", task: "inner", lane: "mill", from: at(15), to: at(16), setup: min(30) },
1428
+ ];
1429
+
1430
+ const MOVES: Transport[] = [
1431
+ { id: "outer-move", from: "o-1", to: "o-2", duration: min(60) },
1432
+ { id: "inner-move", from: "i-1", to: "i-2", duration: min(60), leaves: "main", arrives: "main" },
1433
+ ];
1434
+
1435
+ export default function Anchors() {
1436
+ return (
1437
+ <Schedule ariaLabel="Two transports with different anchors" initialDomain={[at(6, 30), at(16, 30)]} height={150}>
1438
+ <Lane id="lathe" label="Lathe" />
1439
+ <Lane id="mill" label="Mill" />
1440
+ <Transports data={MOVES} />
1441
+ <Subtasks data={WORK} tasks={TASKS} />
1442
+ </Schedule>
1443
+ );
1444
+ }
1445
+ ```
1446
+
1447
+ ##### Where a line touches its bars
1448
+
1449
+ ```tsx
1450
+ import { Stack, Text } from "@umriss-ui/core";
1451
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1452
+ import type { Subtask, Task, Transport, TransportAttachment } from "@umriss-ui/schedule";
1453
+
1454
+ /* `attach` says where a transport's line begins and ends ON the bars: the
1455
+ `"centre"` of both, or the `"nearest"` edge - the one facing the other stop.
1456
+
1457
+ `"nearest"` is the interesting one. A line leaves the lower edge of the
1458
+ upper bar and meets the upper edge of the lower one, so a short move between
1459
+ two neighbouring lanes is drawn as the short line it is instead of swinging
1460
+ out of its lane and back. Within one lane there is no nearer edge, and both
1461
+ mean the middle.
1462
+
1463
+ Not to be confused with the two ANCHORS, which say what a transport
1464
+ connects and therefore whether it is late. This says only where the line
1465
+ touches, and changes no finding. */
1466
+
1467
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1468
+ const min = (n: number) => n * 60_000;
1469
+
1470
+ const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
1471
+
1472
+ const STEPS: Subtask[] = [
1473
+ { id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8, 30) },
1474
+ { id: "mill", task: "frame", lane: "mill", from: at(9), to: at(10, 30) },
1475
+ { id: "press", task: "frame", lane: "press", from: at(11), to: at(12) },
1476
+ /* Back up to the saw's lane: this is the move whose edges the attach
1477
+ decides. */
1478
+ { id: "check", task: "frame", lane: "saw", from: at(12, 30), to: at(13) },
1479
+ ];
1480
+
1481
+ const MOVES: Transport[] = [
1482
+ { id: "to-mill", from: "saw", to: "mill", duration: min(20) },
1483
+ { id: "to-press", from: "mill", to: "press", duration: min(20) },
1484
+ { id: "to-check", from: "press", to: "check", duration: min(20) },
1485
+ ];
1486
+
1487
+ function Variant({ attach }: { attach: TransportAttachment }) {
1488
+ return (
1489
+ <Stack gap={1}>
1490
+ <Text size="xs" tone="muted" mono>
1491
+ attach=&quot;{attach}&quot;
1492
+ </Text>
1493
+ <Schedule ariaLabel={`A frame through three stations, lines from the ${attach}`} initialDomain={[at(6, 30), at(13, 30)]} height={188} attach={attach}>
1494
+ <Lane id="saw" label="Saw" />
1495
+ <Lane id="mill" label="Mill" />
1496
+ <Lane id="press" label="Press" />
1497
+ <Transports data={MOVES} />
1498
+ <Subtasks data={STEPS} tasks={TASKS} />
1499
+ </Schedule>
1500
+ </Stack>
1501
+ );
1502
+ }
1503
+
1504
+ export default function Attach() {
1505
+ return (
1506
+ <Stack gap={4}>
1507
+ <Variant attach="centre" />
1508
+ <Variant attach="nearest" />
1509
+ </Stack>
1510
+ );
1511
+ }
1512
+ ```
1513
+
1514
+ ##### Whether the ends carry a dot
1515
+
1516
+ ```tsx
1517
+ import { Stack, Text } from "@umriss-ui/core";
1518
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1519
+ import type { Subtask, Task, Transport, TransportEnds } from "@umriss-ui/schedule";
1520
+
1521
+ /* `ends` decides whether a transport's two ends are marked with a dot. The dot
1522
+ says where the line is anchored - a help while a plan is being read, and
1523
+ noise in a plan full of short moves. So it is the caller's choice, and a
1524
+ single transport may say otherwise for itself.
1525
+
1526
+ Picture only, like `route` and `attach`: no finding follows from it. */
1527
+
1528
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1529
+ const min = (n: number) => n * 60_000;
1530
+
1531
+ const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
1532
+
1533
+ const STEPS: Subtask[] = [
1534
+ { id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8) },
1535
+ { id: "mill", task: "frame", lane: "mill", from: at(8, 30), to: at(9, 30) },
1536
+ { id: "press", task: "frame", lane: "press", from: at(10), to: at(11) },
1537
+ ];
1538
+
1539
+ const MOVES: Transport[] = [
1540
+ { id: "to-mill", from: "saw", to: "mill", duration: min(15) },
1541
+ { id: "to-press", from: "mill", to: "press", duration: min(15) },
1542
+ ];
1543
+
1544
+ function Variant({ ends }: { ends: TransportEnds }) {
1545
+ return (
1546
+ <Stack gap={1}>
1547
+ <Text size="xs" tone="muted" mono>
1548
+ ends=&quot;{ends}&quot;
1549
+ </Text>
1550
+ <Schedule ariaLabel={`Three short moves, ends ${ends}`} initialDomain={[at(6, 30), at(11, 30)]} height={188} attach="nearest" ends={ends}>
1551
+ <Lane id="saw" label="Saw" />
1552
+ <Lane id="mill" label="Mill" />
1553
+ <Lane id="press" label="Press" />
1554
+ <Transports data={MOVES} />
1555
+ <Subtasks data={STEPS} tasks={TASKS} />
1556
+ </Schedule>
1557
+ </Stack>
1558
+ );
1559
+ }
1560
+
1561
+ export default function Ends() {
1562
+ return (
1563
+ <Stack gap={4}>
1564
+ <Variant ends="dot" />
1565
+ <Variant ends="none" />
1566
+ </Stack>
1567
+ );
1568
+ }
1569
+ ```
1570
+
1571
+ ##### A transport that cannot arrive in time
1572
+
1573
+ ```tsx
1574
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1575
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
1576
+
1577
+ /* The forklift needs forty minutes from the saw to the press; the plan gives it
1578
+ twenty-five. A late transport is drawn dashed in the colour of a finding, as
1579
+ loud as a double booking, and the schedule moves nothing to make it fit.
1580
+ Whether the press waits or the saw starts earlier is the planner's decision -
1581
+ `ripple` offers the arithmetic for the first. */
1582
+
1583
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1584
+ const min = (n: number) => n * 60_000;
1585
+
1586
+ const TASKS: Task[] = [{ id: "frame", color: "light-dark(#c2410c, #f08a52)" }];
1587
+
1588
+ const WORK: Subtask[] = [
1589
+ { id: "cut", task: "frame", lane: "saw", from: at(7), to: at(8, 30), teardown: min(5) },
1590
+ { id: "press", task: "frame", lane: "press", from: at(9, 15), to: at(10, 30), setup: min(15) },
1591
+ ];
1592
+
1593
+ const MOVES: Transport[] = [{ id: "forklift", from: "cut", to: "press", duration: min(40) }];
1594
+
1595
+ export default function LateTransport() {
1596
+ return (
1597
+ <Schedule ariaLabel="A late transport from the saw to the press" initialDomain={[at(6, 30), at(11)]} height={150}>
1598
+ <Lane id="saw" label="Saw" />
1599
+ <Lane id="press" label="Press" />
1600
+ <Transports data={MOVES} />
1601
+ <Subtasks data={WORK} tasks={TASKS} />
1602
+ </Schedule>
1603
+ );
1604
+ }
1605
+ ```
1606
+
1607
+ #### API
1608
+
1609
+ ##### `TransportsProps`
1610
+
1611
+ | Prop | Type | Default | Description |
1612
+ |---|---|---|---|
1613
+ | `data` (required) | `readonly Transport[]` | — | The transports, as the caller holds them. |
1614
+
1615
+ ##### `Transport`
1616
+
1617
+ | Prop | Type | Default | Description |
1618
+ |---|---|---|---|
1619
+ | `id` (required) | `string` | — | The caller's identity of the transport. |
1620
+ | `from` (required) | `string` | — | The id of the subtask it leaves. |
1621
+ | `to` (required) | `string` | — | The id of the subtask it arrives at. |
1622
+ | `duration` (required) | `number` | — | How long the move takes. |
1623
+ | `leaves` | `"main" \| "teardown"` | — | Where it leaves: at the end of the main time, or after the teardown. Default `"teardown"` - the part leaves once the machine is cleared. |
1624
+ | `arrives` | `"main" \| "setup"` | — | Where it arrives: at the start of the main time, or before the setup. Default `"setup"` - the part must be there before the machine is set up. |
1625
+ | `route` | `TransportRoute` | — | How this one is drawn, where it is not drawn like the rest. It changes the picture and never the finding: whether a transport is late follows from `leaves` and `arrives` alone. |
1626
+ | `attach` | `TransportAttachment` | — | 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 transport connects, this says where the line touches. |
1627
+ | `ends` | `TransportEnds` | — | Whether this one's ends are marked with a dot, where it is not marked like the rest. Also picture only. |
1628
+
1629
+ ### Routes
1630
+
1631
+ How a transport is drawn between its two ends: a curve that leaves forwards, a straight line, or orthogonal segments.
1632
+
1633
+ ```ts
1634
+ import { Transports } from "@umriss-ui/schedule";
1635
+ ```
1636
+
1637
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/routes
1638
+
1639
+ #### Examples
1640
+
1641
+ ##### The shape of a transport's line
1642
+
1643
+ ```tsx
1644
+ import { Stack, Text } from "@umriss-ui/core";
1645
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1646
+ import type { Subtask, Task, Transport, TransportRoute } from "@umriss-ui/schedule";
1647
+
1648
+ /* `route` gives a transport's line its shape, and nothing else:
1649
+
1650
+ "curve" leaves forwards and arrives forwards - the default
1651
+ "straight" the shortest line between its two ends
1652
+ "orthogonal" out of the bar, across, and in again
1653
+
1654
+ A curve that leaves and arrives forwards says which way the work is going,
1655
+ even where the arrival lies before the departure - such a move loops back,
1656
+ which is the picture of what it is. Straight lines suit a plan read from a
1657
+ distance; orthogonal ones suit a plant whose moves really do run along
1658
+ aisles.
1659
+
1660
+ It is the schedule's option, and a single transport may say otherwise for
1661
+ itself. Where its line touches the bars is `attach`, and whether its ends
1662
+ carry a dot is `ends` - both on the Transports page, each its own thing.
1663
+
1664
+ None of the three touches a finding. Whether a transport can arrive in time
1665
+ follows from its `leaves` and `arrives`, which say what it connects; these
1666
+ say only how it looks. */
1667
+
1668
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1669
+ const min = (n: number) => n * 60_000;
1670
+
1671
+ const TASKS: Task[] = [{ id: "frame", color: "light-dark(#2563eb, #6b9bff)" }];
1672
+
1673
+ const STEPS: Subtask[] = [
1674
+ { id: "saw", task: "frame", lane: "saw", from: at(7), to: at(8, 30) },
1675
+ { id: "mill", task: "frame", lane: "mill", from: at(9), to: at(10, 30) },
1676
+ { id: "press", task: "frame", lane: "press", from: at(11), to: at(12) },
1677
+ { id: "check", task: "frame", lane: "saw", from: at(12, 30), to: at(13) },
1678
+ ];
1679
+
1680
+ const MOVES: Transport[] = [
1681
+ { id: "to-mill", from: "saw", to: "mill", duration: min(20) },
1682
+ { id: "to-press", from: "mill", to: "press", duration: min(20) },
1683
+ /* Back up to the saw's lane: the attach decides which edges this one meets. */
1684
+ { id: "to-check", from: "press", to: "check", duration: min(20) },
1685
+ ];
1686
+
1687
+ function Variant({ route }: { route: TransportRoute }) {
1688
+ return (
1689
+ <Stack gap={1}>
1690
+ <Text size="xs" tone="muted" mono>
1691
+ route=&quot;{route}&quot;
1692
+ </Text>
1693
+ <Schedule
1694
+ ariaLabel={`A frame through three stations, drawn ${route}`}
1695
+ initialDomain={[at(6, 30), at(13, 30)]}
1696
+ height={188}
1697
+ route={route}
1698
+ attach="nearest"
1699
+ >
1700
+ <Lane id="saw" label="Saw" />
1701
+ <Lane id="mill" label="Mill" />
1702
+ <Lane id="press" label="Press" />
1703
+ <Transports data={MOVES} />
1704
+ <Subtasks data={STEPS} tasks={TASKS} />
1705
+ </Schedule>
1706
+ </Stack>
1707
+ );
1708
+ }
1709
+
1710
+ export default function Routes() {
1711
+ return (
1712
+ <Stack gap={4}>
1713
+ <Variant route="curve" />
1714
+ <Variant route="straight" />
1715
+ <Variant route="orthogonal" />
1716
+ </Stack>
1717
+ );
1718
+ }
1719
+ ```
1720
+
1721
+ ## Reading
1722
+
1723
+ What a planner takes out of the picture: what is selected, what the pointer is on, and what a second view makes of it.
1724
+
1725
+ ### Selection
1726
+
1727
+ A click takes a whole task, across every lane - controlled or kept by the schedule, with the stop that was hit.
1728
+
1729
+ ```ts
1730
+ import { Schedule } from "@umriss-ui/schedule";
1731
+ ```
1732
+
1733
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/selection
1734
+
1735
+ #### Examples
1736
+
1737
+ ##### Selecting a task
1738
+
1739
+ ```tsx
1740
+ import { useState } from "react";
1741
+ import { Stack, Text } from "@umriss-ui/core";
1742
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1743
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
1744
+
1745
+ /* A click on a subtask selects its whole task: every subtask and every
1746
+ transport of it is outlined, across all lanes. A click on a transport selects
1747
+ the task it belongs to, a click on the empty plot clears the selection.
1748
+
1749
+ Here the selection is controlled - `selectedTask` and
1750
+ `onSelectedTaskChange` -, so the application can show what is selected
1751
+ elsewhere. Leave both out and the schedule keeps the selection itself.
1752
+
1753
+ The callback also carries the subtask that was clicked, so an application can
1754
+ show the stop and not only the order; clicking another stop of the same order
1755
+ reports again. Only the task is controlled - which stop is meant follows the
1756
+ pointer. */
1757
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1758
+ const min = (n: number) => n * 60_000;
1759
+
1760
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
1761
+
1762
+
1763
+ const STATIONS = [
1764
+ { id: "saw", label: "Saw 1" },
1765
+ { id: "lathe-1", label: "Lathe 1" },
1766
+ { id: "mill", label: "Mill" },
1767
+ { id: "press", label: "Press 2" },
1768
+ { id: "paint", label: "Paint shop" },
1769
+ ];
1770
+
1771
+ const ORDERS: readonly Task[] = [
1772
+ { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
1773
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
1774
+ ];
1775
+
1776
+ const STEPS: readonly Subtask[] = [
1777
+ { id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
1778
+ { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
1779
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
1780
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
1781
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
1782
+ ];
1783
+
1784
+ const MOVES: readonly Transport[] = [
1785
+ { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", duration: min(15) },
1786
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
1787
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
1788
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
1789
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
1790
+ ];
1791
+
1792
+ export default function Selection() {
1793
+ const [selected, setSelected] = useState<string | null>("a-2043");
1794
+ const [stop, setStop] = useState<string | null>(null);
1795
+ const order = ORDERS.find((o) => o.id === selected);
1796
+
1797
+ return (
1798
+ <Stack gap={3}>
1799
+ <Schedule
1800
+ ariaLabel="Plan of Tuesday, 17 March"
1801
+ initialDomain={DAY_OF_PLAN}
1802
+ height={284}
1803
+ selectedTask={selected}
1804
+ onSelectedTaskChange={(task, subtask) => {
1805
+ setSelected(task);
1806
+ setStop(subtask);
1807
+ }}
1808
+ >
1809
+ {STATIONS.map((station) => (
1810
+ <Lane key={station.id} id={station.id} label={station.label} />
1811
+ ))}
1812
+ <Transports data={MOVES} />
1813
+ <Subtasks data={STEPS} tasks={ORDERS} />
1814
+ </Schedule>
1815
+ <Text size="sm" tone="secondary" data-selected-order>
1816
+ Selected: {order?.name ?? "nothing"}
1817
+ {stop !== null ? `, at ${stop}` : ""}
1818
+ </Text>
1819
+ </Stack>
1820
+ );
1821
+ }
1822
+ ```
1823
+
1824
+ ### Interactions
1825
+
1826
+ Click, context menu and hover, each with its target and its position - for a menu, a detail panel or a status line.
1827
+
1828
+ ```ts
1829
+ import { Schedule } from "@umriss-ui/schedule";
1830
+ ```
1831
+
1832
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/interactions
1833
+
1834
+ #### Examples
1835
+
1836
+ ##### What the pointer reports
1837
+
1838
+ ```tsx
1839
+ import { useState } from "react";
1840
+ import { Stack, Text } from "@umriss-ui/core";
1841
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1842
+ import type { ScheduleInteraction, Subtask, Task, Transport } from "@umriss-ui/schedule";
1843
+
1844
+ /* `onInteraction` hears a click, a context menu and a change of what is under
1845
+ the pointer - each with its target and its position: the subtask and the part
1846
+ of it, the transport, the lane, or nothing; the client point, the time and
1847
+ the lane under it. What the application does with it is its own: open a
1848
+ menu, show a detail, fill a status line like this one.
1849
+
1850
+ While `onInteraction` is set, a right-click opens no browser menu - the
1851
+ application is expected to answer it.
1852
+
1853
+ `onSelectedTaskChange` is the second line below: a click reports the task it
1854
+ selected together with the stop it hit, and clicking another stop of the same
1855
+ task reports again. */
1856
+
1857
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1858
+ const min = (n: number) => n * 60_000;
1859
+
1860
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
1861
+
1862
+
1863
+ const STATIONS = [
1864
+ { id: "saw", label: "Saw 1" },
1865
+ { id: "mill", label: "Mill" },
1866
+ ];
1867
+
1868
+ const ORDERS: readonly Task[] = [
1869
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
1870
+ ];
1871
+
1872
+ const STEPS: readonly Subtask[] = [
1873
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
1874
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
1875
+ ];
1876
+
1877
+ const MOVES: readonly Transport[] = [
1878
+ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
1879
+ ];
1880
+
1881
+ const describe = (interaction: ScheduleInteraction): string => {
1882
+ const { hit } = interaction;
1883
+ /* To the nearest minute: a pixel is about a minute wide here. */
1884
+ const time = new Date(Math.round(interaction.time / 60_000) * 60_000).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
1885
+ const target =
1886
+ hit.kind === "subtask"
1887
+ ? `subtask ${hit.subtask.id} (${hit.part})`
1888
+ : hit.kind === "transport"
1889
+ ? `transport ${hit.transport.id}`
1890
+ : hit.kind === "lane"
1891
+ ? `lane ${hit.lane}`
1892
+ : "nothing";
1893
+ return `${interaction.type}: ${target} at ${time}`;
1894
+ };
1895
+
1896
+ export default function Interactions() {
1897
+ const [last, setLast] = useState("Move the pointer over the plan");
1898
+ const [selected, setSelected] = useState("nothing selected");
1899
+
1900
+ return (
1901
+ <Stack gap={3}>
1902
+ <Schedule
1903
+ ariaLabel="Plan of Tuesday, 17 March"
1904
+ initialDomain={DAY_OF_PLAN}
1905
+ height={152}
1906
+ onInteraction={(interaction) => setLast(describe(interaction))}
1907
+ onSelectedTaskChange={(task, subtask) => setSelected(task === null ? "nothing selected" : `${task} at ${subtask ?? "-"}`)}
1908
+ >
1909
+ {STATIONS.map((station) => (
1910
+ <Lane key={station.id} id={station.id} label={station.label} />
1911
+ ))}
1912
+ <Transports data={MOVES} />
1913
+ <Subtasks data={STEPS} tasks={ORDERS} />
1914
+ </Schedule>
1915
+ <Text size="sm" mono tone="secondary" data-last-interaction>
1916
+ {last}
1917
+ </Text>
1918
+ <Text size="sm" mono tone="secondary" data-selection>
1919
+ {selected}
1920
+ </Text>
1921
+ </Stack>
1922
+ );
1923
+ }
1924
+ ```
1925
+
1926
+ #### API
1927
+
1928
+ ##### `ScheduleInteraction`
1929
+
1930
+ | Prop | Type | Default | Description |
1931
+ |---|---|---|---|
1932
+ | `type` (required) | `"click" \| "contextmenu" \| "hover"` | — | `"hover"` is reported when the target under the pointer changes, not on every movement. |
1933
+ | `hit` (required) | `ScheduleHit` | — | What the pointer is on. |
1934
+ | `clientX` (required) | `number` | — | The pointer's horizontal client coordinate - where a `ContextMenu` opens. |
1935
+ | `clientY` (required) | `number` | — | The pointer's vertical client coordinate. |
1936
+ | `time` (required) | `number` | — | The wall-clock instant under the pointer. |
1937
+ | `lane` (required) | `string \| null` | — | The lane under the pointer, or null below the last one. |
1938
+
1939
+ ### Tooltip
1940
+
1941
+ What the pointer rests on, named with its times and its findings - or replaced by content of the application's own.
1942
+
1943
+ ```ts
1944
+ import { Schedule } from "@umriss-ui/schedule";
1945
+ ```
1946
+
1947
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/tooltip
1948
+
1949
+ #### Examples
1950
+
1951
+ ##### What the tooltip says
1952
+
1953
+ ```tsx
1954
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
1955
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
1956
+
1957
+ /* Resting the pointer on a subtask names the order, the stop, its main time
1958
+ and - where it has them - its setup and teardown, and then every finding on
1959
+ it: with whom it overlaps, and by how much a transport of it is late. Try
1960
+ the bracket in the paint shop at noon, or the housing on the mill at nine.
1961
+
1962
+ On a transport it names where the move goes from and to, how long it takes,
1963
+ and whether it can arrive in time.
1964
+
1965
+ Every word comes from the wording of @umriss-ui/core and every number from
1966
+ its formats, so a provider switches the tooltip with everything else. It
1967
+ steps aside while a drag is in flight, and it is placed from its measured
1968
+ size: beside the pointer where there is room, on the other side where there
1969
+ is not. Nothing here configures it - this is what a schedule does by
1970
+ itself. */
1971
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1972
+ const min = (n: number) => n * 60_000;
1973
+
1974
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
1975
+
1976
+
1977
+ const STATIONS = [
1978
+ { id: "mill", label: "Mill" },
1979
+ { id: "press", label: "Press 2" },
1980
+ { id: "paint", label: "Paint shop" },
1981
+ ];
1982
+
1983
+ const ORDERS: readonly Task[] = [
1984
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
1985
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
1986
+ ];
1987
+
1988
+ const STEPS: readonly Subtask[] = [
1989
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
1990
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
1991
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
1992
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
1993
+ ];
1994
+
1995
+ const MOVES: readonly Transport[] = [
1996
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
1997
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
1998
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
1999
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
2000
+ ];
2001
+
2002
+ export default function Tooltip() {
2003
+ return (
2004
+ <Schedule ariaLabel="Plan of Tuesday, 17 March, with its tooltip" initialDomain={DAY_OF_PLAN} height={196}>
2005
+ {STATIONS.map((station) => (
2006
+ <Lane key={station.id} id={station.id} label={station.label} />
2007
+ ))}
2008
+ <Transports data={MOVES} />
2009
+ <Subtasks data={STEPS} tasks={ORDERS} />
2010
+ </Schedule>
2011
+ );
2012
+ }
2013
+ ```
2014
+
2015
+ ##### A tooltip of the application's own
2016
+
2017
+ ```tsx
2018
+ import { Badge, Stack, Text } from "@umriss-ui/core";
2019
+ import { Lane, Schedule, Subtasks, Transports } from "@umriss-ui/schedule";
2020
+ import type { ScheduleTooltipTarget, Subtask, Task, Transport } from "@umriss-ui/schedule";
2021
+
2022
+ /* Resting the pointer on a subtask or a transport shows a tooltip: by default
2023
+ the order, the stop, its times, setup and teardown, and every finding on it -
2024
+ try the bracket in the paint shop at noon. Its words come from the provider's
2025
+ wording, so a German application reads it in German.
2026
+
2027
+ `tooltip` takes a function instead, which receives what the pointer rests on
2028
+ together with its findings and returns the application's own content - here
2029
+ a customer that only the application knows. `tooltip={false}` switches it
2030
+ off, for an application with a detail panel of its own. */
2031
+
2032
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2033
+ const min = (n: number) => n * 60_000;
2034
+
2035
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
2036
+
2037
+
2038
+ const STATIONS = [
2039
+ { id: "mill", label: "Mill" },
2040
+ { id: "press", label: "Press 2" },
2041
+ { id: "paint", label: "Paint shop" },
2042
+ ];
2043
+
2044
+ const ORDERS: readonly Task[] = [
2045
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
2046
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
2047
+ ];
2048
+
2049
+ const STEPS: readonly Subtask[] = [
2050
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
2051
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
2052
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
2053
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
2054
+ ];
2055
+
2056
+ const MOVES: readonly Transport[] = [
2057
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
2058
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
2059
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
2060
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
2061
+ ];
2062
+
2063
+ const CUSTOMERS: Record<string, string> = {
2064
+ "a-2041": "Brandt Metalworks",
2065
+ "a-2042": "Keller & Sons",
2066
+ "a-2043": "Northworks",
2067
+ "a-2044": "Brandt Metalworks",
2068
+ "a-2045": "Weiss Hydraulics",
2069
+ "a-2046": "Northworks",
2070
+ };
2071
+
2072
+ function OrderTooltip({ target }: { target: ScheduleTooltipTarget }) {
2073
+ const task = target.task;
2074
+ const findings = target.kind === "subtask" ? target.overlapping.length + target.lateTransports.length : target.late ? 1 : 0;
2075
+ return (
2076
+ <Stack gap={1}>
2077
+ <Text size="sm" weight="semibold">
2078
+ {task?.name ?? "Unknown order"}
2079
+ </Text>
2080
+ <Text size="xs" tone="secondary">
2081
+ {task ? CUSTOMERS[task.id] : ""}
2082
+ </Text>
2083
+ {findings > 0 && <Badge tone="danger">{findings === 1 ? "1 finding" : `${findings} findings`}</Badge>}
2084
+ </Stack>
2085
+ );
2086
+ }
2087
+
2088
+ export default function OwnTooltip() {
2089
+ return (
2090
+ <Schedule
2091
+ ariaLabel="Plan of Tuesday, 17 March, with customers"
2092
+ initialDomain={DAY_OF_PLAN}
2093
+ height={196}
2094
+ tooltip={(target) => <OrderTooltip target={target} />}
2095
+ >
2096
+ {STATIONS.map((station) => (
2097
+ <Lane key={station.id} id={station.id} label={station.label} />
2098
+ ))}
2099
+ <Transports data={MOVES} />
2100
+ <Subtasks data={STEPS} tasks={ORDERS} />
2101
+ </Schedule>
2102
+ );
2103
+ }
2104
+ ```
2105
+
2106
+ #### API
2107
+
2108
+ ##### `ScheduleTooltipTarget`
2109
+
2110
+ | Prop | Type | Default | Description |
2111
+ |---|---|---|---|
2112
+ | `kind` (required) | `"subtask" \| "transport"` | — | A subtask is hovered. A transport is hovered. |
2113
+ | `subtask` | `Subtask` | — | The hovered subtask. |
2114
+ | `task` (required) | `Task \| undefined` | — | Its task, where the tasks name it. |
2115
+ | `overlapping` | `readonly Subtask[]` | — | The subtasks it overlaps with on its lane. |
2116
+ | `lateTransports` | `readonly LateTransport[]` | — | The late transports leaving or reaching it. |
2117
+ | `transport` | `Transport` | — | The hovered transport. |
2118
+ | `from` | `Subtask \| undefined` | — | The subtask it leaves. |
2119
+ | `to` | `Subtask \| undefined` | — | The subtask it reaches. |
2120
+ | `late` | `LateTransport \| undefined` | — | Its finding, where it is late. |
2121
+
2122
+ ### Linked schedules
2123
+
2124
+ Two plans of the same hours, kept in step through the span each reports when it is panned or zoomed.
2125
+
2126
+ ```ts
2127
+ import { Schedule } from "@umriss-ui/schedule";
2128
+ ```
2129
+
2130
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/linked-schedules
2131
+
2132
+ #### Examples
2133
+
2134
+ ##### Two schedules in step
2135
+
2136
+ ```tsx
2137
+ import { useState } from "react";
2138
+ import { Stack, Text } from "@umriss-ui/core";
2139
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
2140
+ import type { Subtask, Task } from "@umriss-ui/schedule";
2141
+
2142
+ /* `onDomainChange` reports the visible span after the planner pans or zooms -
2143
+ two wall-clock instants, at most once per frame. Handed to a second schedule
2144
+ as its `initialDomain`, the two move together: pan or zoom either one and the
2145
+ other follows. A span handed in is not reported back, so the two do not feed
2146
+ each other.
2147
+
2148
+ The same span goes to a chart's time axis where an application wants the
2149
+ plan and the measurements of the same hours under one another.
2150
+
2151
+ The two plans can stand a frame apart while a drag is in flight: a span is
2152
+ reported once per frame, and the round trip through the application's state
2153
+ arrives after the plan has moved on. It settles as soon as the drag ends. */
2154
+
2155
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2156
+ const min = (n: number) => n * 60_000;
2157
+
2158
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
2159
+
2160
+
2161
+ const STATIONS = [
2162
+ { id: "saw", label: "Saw 1" },
2163
+ { id: "lathe-1", label: "Lathe 1" },
2164
+ { id: "mill", label: "Mill" },
2165
+ { id: "press", label: "Press 2" },
2166
+ { id: "paint", label: "Paint shop" },
2167
+ { id: "qa", label: "Inspection" },
2168
+ ];
2169
+
2170
+ const ORDERS: readonly Task[] = [
2171
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
2172
+ { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
2173
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
2174
+ ];
2175
+
2176
+ const STEPS: readonly Subtask[] = [
2177
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
2178
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
2179
+ { id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
2180
+ { id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
2181
+ { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
2182
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
2183
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
2184
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
2185
+ ];
2186
+
2187
+ const MACHINES = STATIONS.slice(0, 4);
2188
+ const ON_MACHINES = STEPS.filter((step) => MACHINES.some((machine) => machine.id === step.lane));
2189
+ const REST = STATIONS.slice(4);
2190
+ const ON_REST = STEPS.filter((step) => REST.some((machine) => machine.id === step.lane));
2191
+ export default function InStep() {
2192
+ const [domain, setDomain] = useState(DAY_OF_PLAN);
2193
+ return (
2194
+ <Stack gap={2}>
2195
+ <Schedule
2196
+ ariaLabel="The machines of Tuesday, 17 March"
2197
+ initialDomain={domain}
2198
+ height={240}
2199
+ onDomainChange={setDomain}
2200
+ >
2201
+ {MACHINES.map((machine) => (
2202
+ <Lane key={machine.id} id={machine.id} label={machine.label} />
2203
+ ))}
2204
+ <Subtasks data={ON_MACHINES} tasks={ORDERS} />
2205
+ </Schedule>
2206
+ <Schedule ariaLabel="The rest of the plant, the same hours" initialDomain={domain} height={152} onDomainChange={setDomain}>
2207
+ {REST.map((machine) => (
2208
+ <Lane key={machine.id} id={machine.id} label={machine.label} />
2209
+ ))}
2210
+ <Subtasks data={ON_REST} tasks={ORDERS} />
2211
+ </Schedule>
2212
+ <Text size="xs" mono tone="muted" data-span>
2213
+ {new Date(domain[0]).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} –{" "}
2214
+ {new Date(domain[1]).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}
2215
+ </Text>
2216
+ </Stack>
2217
+ );
2218
+ }
2219
+ ```
2220
+
2221
+ ### The handle
2222
+
2223
+ The arithmetic between a point on the screen and a time on a lane, for an application's own marks beside the plot.
2224
+
2225
+ ```ts
2226
+ import { Schedule } from "@umriss-ui/schedule";
2227
+ ```
2228
+
2229
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/handle
2230
+
2231
+ #### Examples
2232
+
2233
+ ##### A mark of the application's own
2234
+
2235
+ ```tsx
2236
+ import { useEffect, useRef, useState } from "react";
2237
+ import { Stack, Text } from "@umriss-ui/core";
2238
+ import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
2239
+ import type { ScheduleHandle, Subtask, Task } from "@umriss-ui/schedule";
2240
+
2241
+ /* `ScheduleHandle` is everything a schedule offers imperatively, and it is
2242
+ arithmetic and nothing else: the two directions between a point on the
2243
+ screen and a time on a lane.
2244
+
2245
+ clientPointOf(time, lane?) the client point of a time, on a lane's middle
2246
+ positionAt(clientX, clientY) the time and the lane at a client point
2247
+ visibleDomain() the span in view, as two wall-clock instants
2248
+
2249
+ Everything else a schedule does is props. This is here because an
2250
+ application's own marks - a shift change, a delivery window, a pin on the
2251
+ order somebody just rang about - are the application's DOM, not the
2252
+ schedule's, and they have to be placed.
2253
+
2254
+ The pin above the plan is placed with `clientPointOf`, turned into a point
2255
+ inside this example, and placed anew whenever the span moves. Pan or zoom
2256
+ the plan and watch it follow. */
2257
+
2258
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2259
+ const min = (n: number) => n * 60_000;
2260
+
2261
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
2262
+
2263
+ const STATIONS = [
2264
+ { id: "saw", label: "Saw 1" },
2265
+ { id: "mill", label: "Mill" },
2266
+ { id: "press", label: "Press 2" },
2267
+ ];
2268
+
2269
+ const ORDERS: readonly Task[] = [
2270
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
2271
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
2272
+ ];
2273
+
2274
+ const STEPS: readonly Subtask[] = [
2275
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
2276
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
2277
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
2278
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(12), to: at(13, 30), setup: min(15) },
2279
+ ];
2280
+
2281
+ const SHIFT_CHANGE = at(14);
2282
+
2283
+ export default function Handle() {
2284
+ const plan = useRef<ScheduleHandle>(null);
2285
+ const host = useRef<HTMLDivElement>(null);
2286
+ const [domain, setDomain] = useState(DAY_OF_PLAN);
2287
+ const [pin, setPin] = useState<number | null>(null);
2288
+ const [under, setUnder] = useState("Move the pointer over the plan");
2289
+
2290
+ /* Placed relative to this example, so the client point is turned into one
2291
+ inside it - and placed anew whenever the span moves. */
2292
+ useEffect(() => {
2293
+ const point = plan.current?.clientPointOf(SHIFT_CHANGE);
2294
+ const left = host.current?.getBoundingClientRect().left;
2295
+ setPin(point === undefined || point === null || left === undefined ? null : point.x - left);
2296
+ }, [domain]);
2297
+
2298
+ return (
2299
+ <Stack gap={2}>
2300
+ <div ref={host} style={{ position: "relative", height: 12 }}>
2301
+ {pin !== null && (
2302
+ <Text size="xs" tone="muted" style={{ position: "absolute", top: -4, left: pin, transform: "translateX(-50%)" }} data-pin>
2303
+ Shift change
2304
+ </Text>
2305
+ )}
2306
+ </div>
2307
+ <Schedule
2308
+ ref={plan}
2309
+ ariaLabel="A plan with a mark of the application's own"
2310
+ initialDomain={domain}
2311
+ height={196}
2312
+ onDomainChange={setDomain}
2313
+ onInteraction={(interaction) => {
2314
+ /* `positionAt` is the other direction: a client point back to a time
2315
+ and a lane. The interaction already carries both - this is for the
2316
+ application's own listeners, which have only a point. */
2317
+ const there = plan.current?.positionAt(interaction.clientX, interaction.clientY);
2318
+ setUnder(
2319
+ there == null
2320
+ ? "off the plan"
2321
+ : `${new Date(there.time).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} on ${there.lane ?? "no lane"}`,
2322
+ );
2323
+ }}
2324
+ >
2325
+ {STATIONS.map((station) => (
2326
+ <Lane key={station.id} id={station.id} label={station.label} />
2327
+ ))}
2328
+ <Subtasks data={STEPS} tasks={ORDERS} />
2329
+ </Schedule>
2330
+ <Text size="sm" mono tone="secondary" data-under-pointer>
2331
+ {under}
2332
+ </Text>
2333
+ </Stack>
2334
+ );
2335
+ }
2336
+ ```
2337
+
2338
+ ## Editing
2339
+
2340
+ Direct manipulation that changes nothing by itself: the schedule reports, the application decides.
2341
+
2342
+ ### Move and lane
2343
+
2344
+ Dragging a subtask through time and onto another lane - the ghost while it is in flight, the intents when it ends.
2345
+
2346
+ ```ts
2347
+ import { Schedule, applyIntent } from "@umriss-ui/schedule";
2348
+ ```
2349
+
2350
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/move-and-lane
2351
+
2352
+ #### Examples
2353
+
2354
+ ##### Moving, and putting on another lane
2355
+
2356
+ ```tsx
2357
+ import { useState } from "react";
2358
+ import { Stack, Text } from "@umriss-ui/core";
2359
+ import { Lane, Schedule, Subtasks, Transports, applyIntent } from "@umriss-ui/schedule";
2360
+ import type { Intent, Subtask, Task, Transport } from "@umriss-ui/schedule";
2361
+
2362
+ /* `intents` lists what the application handles, and each name enables its
2363
+ interaction: with `move` a subtask is dragged through time, with `lane`
2364
+ onto another lane. Without `intents` the schedule is read-only - that is a
2365
+ feature, not a lesser mode.
2366
+
2367
+ While the drag is in flight a ghost stands beside the unchanged plan, with
2368
+ its new times and whatever finding the drop would create. When it ends, one
2369
+ intent per change is reported. The plan changes only here, where the
2370
+ application applies the intent - `applyIntent` is the arithmetic for it. */
2371
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2372
+ const min = (n: number) => n * 60_000;
2373
+
2374
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
2375
+
2376
+
2377
+ const STATIONS = [
2378
+ { id: "saw", label: "Saw 1" },
2379
+ { id: "mill", label: "Mill" },
2380
+ { id: "press", label: "Press 2" },
2381
+ { id: "paint", label: "Paint shop" },
2382
+ { id: "qa", label: "Inspection" },
2383
+ ];
2384
+
2385
+ const ORDERS: readonly Task[] = [
2386
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
2387
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
2388
+ { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
2389
+ { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
2390
+ ];
2391
+
2392
+ const STEPS: readonly Subtask[] = [
2393
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
2394
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
2395
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
2396
+ { id: "a-2046-2", task: "a-2046", lane: "mill", from: at(13), to: at(14, 30), setup: min(20), teardown: min(10) },
2397
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
2398
+ { id: "a-2045-3", task: "a-2045", lane: "qa", from: at(15, 45), to: at(16, 30) },
2399
+ ];
2400
+
2401
+ const MOVES: readonly Transport[] = [
2402
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
2403
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
2404
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
2405
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
2406
+ ];
2407
+
2408
+ export default function MoveAndLane() {
2409
+ const [steps, setSteps] = useState(STEPS);
2410
+ const [last, setLast] = useState<Intent | null>(null);
2411
+
2412
+ return (
2413
+ <Stack gap={3}>
2414
+ <Schedule
2415
+ ariaLabel="Plan of Tuesday, 17 March, editable"
2416
+ initialDomain={DAY_OF_PLAN}
2417
+ height={284}
2418
+ intents={["move", "lane"]}
2419
+ onIntent={(intent) => {
2420
+ setLast(intent);
2421
+ setSteps((current) => current.map((step) => applyIntent(step, intent)));
2422
+ }}
2423
+ >
2424
+ {STATIONS.map((station) => (
2425
+ <Lane key={station.id} id={station.id} label={station.label} />
2426
+ ))}
2427
+ <Transports data={MOVES} />
2428
+ <Subtasks data={steps} tasks={ORDERS} />
2429
+ </Schedule>
2430
+ <Text size="sm" mono tone="secondary" data-last-intent>
2431
+ {last === null ? "No intent yet" : JSON.stringify(last)}
2432
+ </Text>
2433
+ </Stack>
2434
+ );
2435
+ }
2436
+ ```
2437
+
2438
+ ### Stretch, setup, teardown
2439
+
2440
+ The other three edits: the edges of the main time, and the two grips a selected subtask offers for its preparation.
2441
+
2442
+ ```ts
2443
+ import { Schedule, applyIntent } from "@umriss-ui/schedule";
2444
+ ```
2445
+
2446
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/stretch
2447
+
2448
+ #### Examples
2449
+
2450
+ ##### Stretching, setup and teardown
2451
+
2452
+ ```tsx
2453
+ import { useState } from "react";
2454
+ import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
2455
+ import type { Subtask, Task } from "@umriss-ui/schedule";
2456
+
2457
+ /* The edges of the main time stretch it (`stretch`). Setup and teardown have
2458
+ grips of their own, and they appear only on the selected subtask: a plan
2459
+ where every bar bristles with handles is a plan nobody can read. Click a
2460
+ subtask, then drag the grip at its outer edge (`setup`, `teardown`).
2461
+
2462
+ Each is changed on its own - stretching the main time leaves the setup as
2463
+ long as it was. */
2464
+
2465
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2466
+ const min = (n: number) => n * 60_000;
2467
+
2468
+ const TASKS: Task[] = [{ id: "shaft", color: "light-dark(#0d9488, #3cc7b8)" }];
2469
+
2470
+ const START: Subtask[] = [
2471
+ { id: "turn", task: "shaft", lane: "lathe", from: at(8), to: at(10), setup: min(30), teardown: min(15) },
2472
+ { id: "grind", task: "shaft", lane: "grinder", from: at(11), to: at(12, 30), setup: min(15) },
2473
+ ];
2474
+
2475
+ export default function StretchSetupTeardown() {
2476
+ const [work, setWork] = useState(START);
2477
+
2478
+ return (
2479
+ <Schedule
2480
+ ariaLabel="A shaft on the lathe and the grinder"
2481
+ initialDomain={[at(6), at(13, 30)]}
2482
+ height={150}
2483
+ intents={["stretch", "setup", "teardown"]}
2484
+ onIntent={(intent) => setWork((current) => current.map((s) => applyIntent(s, intent)))}
2485
+ >
2486
+ <Lane id="lathe" label="Lathe" />
2487
+ <Lane id="grinder" label="Grinder" />
2488
+ <Subtasks data={work} tasks={TASKS} />
2489
+ </Schedule>
2490
+ );
2491
+ }
2492
+ ```
2493
+
2494
+ ### Snapping
2495
+
2496
+ The raster a drag lands on - the axis' own step, a duration, or a shift pattern with an offset.
2497
+
2498
+ ```ts
2499
+ import { Schedule, snapTime } from "@umriss-ui/schedule";
2500
+ ```
2501
+
2502
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/snapping
2503
+
2504
+ #### Examples
2505
+
2506
+ ##### Snapping to a raster
2507
+
2508
+ ```tsx
2509
+ import { useState } from "react";
2510
+ import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
2511
+ import type { SnapRaster, Subtask, Task } from "@umriss-ui/schedule";
2512
+
2513
+ /* A drag lands on a raster. By default that is the fine band's current step -
2514
+ hours when zoomed out, quarter hours when zoomed in -, so what a planner drops
2515
+ lands on a line they can read. The upper schedule snaps to the plant's shift
2516
+ changes - eight hours, offset by six from local midnight: 06:00, 14:00,
2517
+ 22:00 on the plant's clock, whatever the time zone -; the lower one does not
2518
+ snap at all.
2519
+
2520
+ Snapping shapes the ghost and therefore the intent. It never touches data
2521
+ that is not being dragged. */
2522
+
2523
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2524
+ const HOUR = 3_600_000;
2525
+
2526
+ const SHIFTS: SnapRaster = { step: 8 * HOUR, offset: 6 * HOUR };
2527
+
2528
+ const TASKS: Task[] = [{ id: "batch", color: "light-dark(#7c3aed, #a98bfa)" }];
2529
+
2530
+ const BATCH: Subtask[] = [{ id: "cure", task: "batch", lane: "oven", from: at(6), to: at(14) }];
2531
+
2532
+ function Oven({ snap, label }: { snap: SnapRaster | false; label: string }) {
2533
+ const [work, setWork] = useState(BATCH);
2534
+ return (
2535
+ <Schedule
2536
+ ariaLabel={label}
2537
+ initialDomain={[at(4), at(24)]}
2538
+ height={110}
2539
+ snap={snap}
2540
+ intents={["move"]}
2541
+ onIntent={(intent) => setWork((current) => current.map((s) => applyIntent(s, intent)))}
2542
+ >
2543
+ <Lane id="oven" label={label} />
2544
+ <Subtasks data={work} tasks={TASKS} />
2545
+ </Schedule>
2546
+ );
2547
+ }
2548
+
2549
+ export default function Snapping() {
2550
+ return (
2551
+ <>
2552
+ <Oven snap={SHIFTS} label="Shifts" />
2553
+ <div style={{ height: 12 }} />
2554
+ <Oven snap={false} label="Free" />
2555
+ </>
2556
+ );
2557
+ }
2558
+ ```
2559
+
2560
+ ### Placing from outside
2561
+
2562
+ Work that is not on the plan yet, dragged in from a list beside it with the browser's own drag and drop.
2563
+
2564
+ ```ts
2565
+ import { Schedule, subtaskFromPlace } from "@umriss-ui/schedule";
2566
+ ```
2567
+
2568
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/placing
2569
+
2570
+ #### Examples
2571
+
2572
+ ##### Dragging unplanned work onto the plan
2573
+
2574
+ ```tsx
2575
+ import { useState } from "react";
2576
+ import { Card, Stack, Text } from "@umriss-ui/core";
2577
+ import { Lane, Schedule, Subtasks, Transports, applyIntent, subtaskFromPlace } from "@umriss-ui/schedule";
2578
+ import type { Intent, PlacingItem, Subtask, Task, Transport } from "@umriss-ui/schedule";
2579
+
2580
+ /* Work that is not on the plan yet is dragged in from a list beside it - with
2581
+ the browser's own drag and drop, so the source can be any list the
2582
+ application has.
2583
+
2584
+ The browser hands the dragged data over only on the drop, so the ghost before
2585
+ it cannot come from there: the application declares what it is dragging in
2586
+ `placing`, on its own `dragstart`, and clears it on `dragend`. While the drag
2587
+ is over a lane, the schedule shows the ghost at the snapped time with the
2588
+ findings the drop would create - the last of the three below collides with
2589
+ the press wherever it is put in the morning.
2590
+
2591
+ The drop reports a `place` intent: the key the application gave the item, the
2592
+ task, the lane and the times. It creates no subtask itself - the id is the
2593
+ application's, and `subtaskFromPlace` builds the subtask from the intent. */
2594
+
2595
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2596
+ const min = (n: number) => n * 60_000;
2597
+
2598
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
2599
+
2600
+
2601
+ const STATIONS = [
2602
+ { id: "saw", label: "Saw 1" },
2603
+ { id: "press", label: "Press 2" },
2604
+ { id: "qa", label: "Inspection" },
2605
+ ];
2606
+
2607
+ const ORDERS: readonly Task[] = [
2608
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
2609
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
2610
+ { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
2611
+ ];
2612
+
2613
+ const STEPS: readonly Subtask[] = [
2614
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
2615
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
2616
+ { id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
2617
+ { id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
2618
+ ];
2619
+
2620
+ const MOVES: readonly Transport[] = [
2621
+ { id: "t-2041-3", from: "a-2041-1", to: "a-2041-3", duration: min(20) },
2622
+ ];
2623
+
2624
+ const HOUR = 3_600_000;
2625
+
2626
+ const WAITING: readonly (PlacingItem & { label: string })[] = [
2627
+ { item: "a-2047", label: "A-2047 Cap · 2 h", task: "a-2041", duration: 2 * HOUR, setup: 15 * 60_000 },
2628
+ { item: "a-2048", label: "A-2048 Ring · 1 h", task: "a-2042", duration: HOUR },
2629
+ { item: "a-2049", label: "A-2049 Plate · 4 h", task: "a-2043", duration: 4 * HOUR, teardown: 20 * 60_000 },
2630
+ ];
2631
+
2632
+ export default function DragIn() {
2633
+ const [plan, setPlan] = useState<{ steps: readonly Subtask[]; placed: number; last: string }>({
2634
+ steps: STEPS,
2635
+ placed: 0,
2636
+ last: "Drag an order onto a lane",
2637
+ });
2638
+ const [placing, setPlacing] = useState<PlacingItem | null>(null);
2639
+
2640
+ /* One functional update, so that a second intent in the same tick - and the
2641
+ counter the new id is built from - starts from the plan the first left. */
2642
+ const onIntent = (intent: Intent) =>
2643
+ setPlan((current) => {
2644
+ if (intent.kind !== "place") {
2645
+ return { ...current, steps: current.steps.map((step) => applyIntent(step, intent)), last: `${intent.kind} ${intent.subtask}` };
2646
+ }
2647
+ const placed = current.placed + 1;
2648
+ const id = `${intent.item}-${placed}`;
2649
+ return {
2650
+ steps: [...current.steps, subtaskFromPlace(intent, id)],
2651
+ placed,
2652
+ last: `place ${intent.item} on ${intent.lane} as ${id}`,
2653
+ };
2654
+ });
2655
+
2656
+ return (
2657
+ <Stack gap={3}>
2658
+ <Stack direction="row" gap={2}>
2659
+ {WAITING.map((order) => (
2660
+ <Card key={order.item}>
2661
+ <div
2662
+ draggable
2663
+ data-waiting={order.item}
2664
+ onDragStart={(event) => {
2665
+ event.dataTransfer.setData("text/plain", order.item);
2666
+ event.dataTransfer.effectAllowed = "copy";
2667
+ setPlacing(order);
2668
+ }}
2669
+ onDragEnd={() => setPlacing(null)}
2670
+ style={{ cursor: "grab" }}
2671
+ >
2672
+ <Text size="sm">{order.label}</Text>
2673
+ </div>
2674
+ </Card>
2675
+ ))}
2676
+ </Stack>
2677
+ <Schedule
2678
+ ariaLabel="Plan of Tuesday, 17 March, with unplanned work beside it"
2679
+ initialDomain={DAY_OF_PLAN}
2680
+ height={196}
2681
+ intents={["place", "move", "lane"]}
2682
+ placing={placing}
2683
+ onIntent={onIntent}
2684
+ >
2685
+ {STATIONS.map((station) => (
2686
+ <Lane key={station.id} id={station.id} label={station.label} />
2687
+ ))}
2688
+ <Transports data={MOVES} />
2689
+ <Subtasks data={plan.steps} tasks={ORDERS} />
2690
+ </Schedule>
2691
+ <Text size="sm" mono tone="secondary" data-last-place>
2692
+ {plan.last}
2693
+ </Text>
2694
+ </Stack>
2695
+ );
2696
+ }
2697
+ ```
2698
+
2699
+ ### Where a subtask may go
2700
+
2701
+ The lanes a piece of work may not go to, marked before the pointer arrives and refused without a warning colour.
2702
+
2703
+ ```ts
2704
+ import { Schedule } from "@umriss-ui/schedule";
2705
+ ```
2706
+
2707
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/where-it-may-go
2708
+
2709
+ #### Examples
2710
+
2711
+ ##### A rule of the plant, seen before it is met
2712
+
2713
+ ```tsx
2714
+ import { useState } from "react";
2715
+ import { Stack, Text } from "@umriss-ui/core";
2716
+ import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
2717
+ import type { Intent, Subtask, Task } from "@umriss-ui/schedule";
2718
+
2719
+ /* Not every stop may go on every machine. A mould fits one press, a part that
2720
+ has been set up may move in time but not to another station, and a plant
2721
+ knows both - the schedule does not.
2722
+
2723
+ `canMoveTo` asks the application, once per lane when the drag takes hold and
2724
+ again at the drop. The lanes it turns down are marked from the first frame:
2725
+ drawn back and hatched, their headers with them, so that nobody has to try a
2726
+ lane to learn it is closed. Over one of them the ghost stays on the last
2727
+ lane that was allowed, the cursor says no, and a line ties the ghost to the
2728
+ pointer it is not following - it is being held, not stuck.
2729
+
2730
+ A refusal costs the lane and nothing else. Drop after it and the move in
2731
+ time is still reported: one rule of the plant must not take away the other
2732
+ half of a gesture. And none of it wears a warning colour, because a mould
2733
+ that fits one press is nobody's mistake.
2734
+
2735
+ It narrows `"lane"`; it does not enable it. Dragging in time keeps working
2736
+ for everything below - a restriction on the lane is not a restriction on the
2737
+ clock.
2738
+
2739
+ This example drags a part that is already on the plan. The same rule holds
2740
+ for work dragged in from a list - that is the next example; how a drag from
2741
+ outside is set up at all is its own chapter, *Placing from outside*. */
2742
+
2743
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2744
+
2745
+ const TASKS: Task[] = [
2746
+ { id: "wide", color: "light-dark(#2563eb, #6b9bff)" },
2747
+ { id: "bound", color: "light-dark(#c2410c, #f08a52)" },
2748
+ ];
2749
+
2750
+ const PRESSES = ["press-1", "press-2"];
2751
+
2752
+ const START: Subtask[] = [
2753
+ { id: "free", task: "wide", lane: "press-1", from: at(7), to: at(9) },
2754
+ { id: "moulded", task: "bound", lane: "press-2", from: at(9, 30), to: at(11, 30) },
2755
+ { id: "welded", task: "wide", lane: "weld", from: at(12), to: at(14) },
2756
+ ];
2757
+
2758
+ /* The mould fits the two presses and nothing else; everything else may go
2759
+ anywhere. A real plant reads this off its own master data. */
2760
+ const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "bound" || PRESSES.includes(lane);
2761
+
2762
+ export default function WhereItMayGo() {
2763
+ const [work, setWork] = useState<readonly Subtask[]>(START);
2764
+ const [last, setLast] = useState("Drag the moulded part onto the welding bay");
2765
+
2766
+ /* Only `move` and `lane` are enabled, so only those two arrive - but the
2767
+ type is the whole union, and a `place` has no subtask to name. */
2768
+ const onIntent = (intent: Intent) => {
2769
+ if (intent.kind === "place") return;
2770
+ setWork((current) => current.map((s) => applyIntent(s, intent)));
2771
+ setLast(`${intent.subtask}: ${intent.kind}`);
2772
+ };
2773
+
2774
+ return (
2775
+ <Stack gap={3}>
2776
+ <Schedule
2777
+ ariaLabel="Two presses and a welding bay"
2778
+ initialDomain={[at(6, 30), at(15)]}
2779
+ height={190}
2780
+ intents={["move", "lane"]}
2781
+ canMoveTo={mayGo}
2782
+ onIntent={onIntent}
2783
+ >
2784
+ <Lane id="press-1" label="Press 1" />
2785
+ <Lane id="press-2" label="Press 2" />
2786
+ <Lane id="weld" label="Welding bay" />
2787
+ <Subtasks data={work} tasks={TASKS} />
2788
+ </Schedule>
2789
+ <Text size="sm" mono tone="secondary" data-last-move>
2790
+ {last}
2791
+ </Text>
2792
+ </Stack>
2793
+ );
2794
+ }
2795
+ ```
2796
+
2797
+ ##### The same rule for work dragged in
2798
+
2799
+ ```tsx
2800
+ import { useState } from "react";
2801
+ import { Card, Stack, Text } from "@umriss-ui/core";
2802
+ import { Lane, Schedule, Subtasks, subtaskFromPlace } from "@umriss-ui/schedule";
2803
+ import type { Intent, PlacingItem, Subtask, Task } from "@umriss-ui/schedule";
2804
+
2805
+ /* `canMoveTo` is asked about work that is not on the plan yet too, with the
2806
+ key and task the application declared in `placing`. The welding bay is
2807
+ marked for the mould beside the plan exactly as it is for the one already on
2808
+ it, and the ghost holds on the last lane that allowed it.
2809
+
2810
+ One thing is the platform's and not the schedule's: a native drag has no
2811
+ cursor of its own to give, only the browser's answer to "may this be
2812
+ dropped". That answer follows the GHOST here and not the pointer - while a
2813
+ ghost stands somewhere allowed, a release lands there, because a ghost is
2814
+ the promise of where a drop lands. It says "no drop" exactly when a release
2815
+ would place nothing.
2816
+
2817
+ How a drag from a list is set up at all - `placing`, `dragstart`, `dragend`,
2818
+ and what a `place` intent carries - is *Placing from outside*. Nothing of it
2819
+ is repeated here. */
2820
+
2821
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2822
+
2823
+ const TASKS: Task[] = [
2824
+ { id: "wide", color: "light-dark(#2563eb, #6b9bff)" },
2825
+ { id: "bound", color: "light-dark(#c2410c, #f08a52)" },
2826
+ ];
2827
+
2828
+ const PRESSES = ["press-1", "press-2"];
2829
+
2830
+ const START: Subtask[] = [
2831
+ { id: "free", task: "wide", lane: "press-1", from: at(7), to: at(9) },
2832
+ { id: "welded", task: "wide", lane: "weld", from: at(12), to: at(14) },
2833
+ ];
2834
+
2835
+ /* The mould fits the two presses and nothing else. */
2836
+ const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "bound" || PRESSES.includes(lane);
2837
+
2838
+ const WAITING: PlacingItem & { label: string } = {
2839
+ item: "mould",
2840
+ label: "Mould A-77 · 2 h",
2841
+ task: "bound",
2842
+ duration: 2 * 60 * 60_000,
2843
+ };
2844
+
2845
+ export default function FromOutside() {
2846
+ const [work, setWork] = useState<readonly Subtask[]>(START);
2847
+ const [placing, setPlacing] = useState<PlacingItem | null>(null);
2848
+ const [last, setLast] = useState("Drag the mould onto the welding bay");
2849
+
2850
+ const onIntent = (intent: Intent) => {
2851
+ if (intent.kind !== "place") return;
2852
+ setWork((current) => [...current, subtaskFromPlace(intent, `${intent.item}-${current.length}`)]);
2853
+ setLast(`${intent.item}: place on ${intent.lane}`);
2854
+ };
2855
+
2856
+ return (
2857
+ <Stack gap={3}>
2858
+ <Card>
2859
+ <div
2860
+ draggable
2861
+ data-waiting={WAITING.item}
2862
+ onDragStart={(event) => {
2863
+ event.dataTransfer.setData("text/plain", WAITING.item);
2864
+ event.dataTransfer.effectAllowed = "copy";
2865
+ setPlacing(WAITING);
2866
+ }}
2867
+ onDragEnd={() => setPlacing(null)}
2868
+ style={{ cursor: "grab" }}
2869
+ >
2870
+ <Text size="sm">{WAITING.label}</Text>
2871
+ </div>
2872
+ </Card>
2873
+ <Schedule
2874
+ ariaLabel="A mould waiting beside two presses and a welding bay"
2875
+ initialDomain={[at(6, 30), at(15)]}
2876
+ height={190}
2877
+ intents={["place"]}
2878
+ placing={placing}
2879
+ canMoveTo={mayGo}
2880
+ onIntent={onIntent}
2881
+ >
2882
+ <Lane id="press-1" label="Press 1" />
2883
+ <Lane id="press-2" label="Press 2" />
2884
+ <Lane id="weld" label="Welding bay" />
2885
+ <Subtasks data={work} tasks={TASKS} />
2886
+ </Schedule>
2887
+ <Text size="sm" mono tone="secondary" data-last-place>
2888
+ {last}
2889
+ </Text>
2890
+ </Stack>
2891
+ );
2892
+ }
2893
+ ```
2894
+
2895
+ #### Why it is like this
2896
+
2897
+ ##### A refusal is not an error
2898
+
2899
+ A mould that fits one press is nobody's mistake. Neither is a part that has been set up and may no longer change station. These are **rules of the plant**, and a planner who meets one has not done anything wrong - they have learned something about the plant. So a refusal here wears no warning colour, no red, no alarm, and no exclamation mark: the lane is drawn back and hatched, the cursor says no, and the word says it once, in the label's own type.
2900
+
2901
+ ##### Seen before it is met, not after
2902
+
2903
+ The lanes a piece of work may not go to are marked the moment the drag takes hold - before the pointer has reached any of them. A refusal that only appears once you have tried is a small punishment for guessing; one that is already drawn is a map. The rule is asked once per lane and held for the gesture, which is also what keeps a plant of hundreds of orders fluid while a drag runs.
2904
+
2905
+ ##### The place is never travelled to and then corrected
2906
+
2907
+ The ghost never stands on a lane the work may not go to. It holds on the last lane that allowed it, with a hairline back to the pointer, so that it reads as held rather than stuck - and so that the ghost stays what it is everywhere else in this component: the promise of where a drop lands.
2908
+
2909
+ And a refusal costs only what it refuses. Drag across a closed lane and an hour earlier, drop, and the hour is still reported. One rule of the plant must not take away the other half of a gesture.
2910
+
2911
+ ### Ripple
2912
+
2913
+ The cascade as arithmetic: the moves that push every successor whose transport no longer fits - for the application to run, or not.
2914
+
2915
+ ```ts
2916
+ import { ripple, shiftTask, applyIntent } from "@umriss-ui/schedule";
2917
+ ```
2918
+
2919
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/ripple
2920
+
2921
+ #### Examples
2922
+
2923
+ ##### Pushing the successors
2924
+
2925
+ ```tsx
2926
+ import { useState } from "react";
2927
+ import { Button, Stack, Text } from "@umriss-ui/core";
2928
+ import { Lane, Schedule, Subtasks, Transports, applyIntent, ripple } from "@umriss-ui/schedule";
2929
+ import type { MoveIntent, Subtask, Task, Transport } from "@umriss-ui/schedule";
2930
+
2931
+ /* The saw runs an hour late. `ripple` takes the data, the transports and that
2932
+ one intent, and returns the moves that push every successor whose transport
2933
+ no longer fits - by exactly what is missing, down the chain, never earlier.
2934
+ The schedule never runs it; whether a successor may move is the plant's
2935
+ decision, and this application decides with a button. */
2936
+
2937
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2938
+ const min = (n: number) => n * 60_000;
2939
+
2940
+ const TASKS: Task[] = [{ id: "bracket", color: "light-dark(#c2410c, #f08a52)" }];
2941
+
2942
+ const START: Subtask[] = [
2943
+ { id: "saw", task: "bracket", lane: "saw", from: at(6), to: at(7, 30) },
2944
+ { id: "bend", task: "bracket", lane: "press", from: at(8), to: at(9) },
2945
+ { id: "weld", task: "bracket", lane: "weld", from: at(9, 30), to: at(11) },
2946
+ ];
2947
+
2948
+ const MOVES: Transport[] = [
2949
+ { id: "to-press", from: "saw", to: "bend", duration: min(20) },
2950
+ { id: "to-weld", from: "bend", to: "weld", duration: min(15) },
2951
+ ];
2952
+
2953
+ const LATE: MoveIntent = { kind: "move", subtask: "saw", from: at(7), to: at(8, 30) };
2954
+
2955
+ export default function Cascade() {
2956
+ const [work, setWork] = useState<readonly Subtask[]>(START);
2957
+ /* The cascade of the late saw - once the saw is late; before that there is
2958
+ nothing to push. */
2959
+ const late = work.some((s) => s.id === LATE.subtask && s.from === LATE.from);
2960
+ const pushed = late ? ripple(work, MOVES, LATE) : [];
2961
+
2962
+ return (
2963
+ <Stack gap={3}>
2964
+ <Schedule ariaLabel="A bracket through saw, press and welding" initialDomain={[at(5, 30), at(13)]} height={190}>
2965
+ <Lane id="saw" label="Saw" />
2966
+ <Lane id="press" label="Press" />
2967
+ <Lane id="weld" label="Welding" />
2968
+ <Transports data={MOVES} />
2969
+ <Subtasks data={work} tasks={TASKS} />
2970
+ </Schedule>
2971
+ <Stack direction="row" gap={2} align="center">
2972
+ <Button size="sm" onClick={() => setWork((current) => current.map((s) => applyIntent(s, LATE)))} disabled={late}>
2973
+ Saw an hour late
2974
+ </Button>
2975
+ <Button size="sm" onClick={() => setWork((current) => pushed.reduce((data, move) => data.map((s) => applyIntent(s, move)), current))} disabled={pushed.length === 0}>
2976
+ Push {pushed.length} {pushed.length === 1 ? "successor" : "successors"}
2977
+ </Button>
2978
+ <Button size="sm" variant="ghost" onClick={() => setWork(START)}>
2979
+ Reset
2980
+ </Button>
2981
+ </Stack>
2982
+ <Text size="xs" mono tone="muted">
2983
+ {pushed.length === 0 ? "Nothing to push" : pushed.map((m) => m.subtask).join(", ")}
2984
+ </Text>
2985
+ </Stack>
2986
+ );
2987
+ }
2988
+ ```
2989
+
2990
+ ### Demonstration
2991
+
2992
+ A whole plant in a planner's hands: a context menu, the cascade, the findings, and every intent the schedule reports.
2993
+
2994
+ ```ts
2995
+ import { Schedule, findings, ripple, shiftTask } from "@umriss-ui/schedule";
2996
+ ```
2997
+
2998
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/demonstration
2999
+
3000
+ #### Examples
3001
+
3002
+ ##### Demonstration: the plan in the planner's hands
3003
+
3004
+ ```tsx
3005
+ import { useState } from "react";
3006
+ import { Checkbox, ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
3007
+ import { Lane, Schedule, Subtasks, Transports, applyIntent, findings, ripple, shiftTask } from "@umriss-ui/schedule";
3008
+ import type { Intent, ScheduleInteraction, Subtask } from "@umriss-ui/schedule";
3009
+ import { DAY_OF_PLAN, MOVES, ORDERS, STATIONS, STEPS } from "../../data";
3010
+
3011
+ /* The one example that does not carry its own data. A demonstration is a whole
3012
+ plant - seven stations, six orders and their moves through a Tuesday - and
3013
+ ninety lines of it in this file would bury the thing it demonstrates. It is
3014
+ shown instead: `data.ts` stands in the second tab of the code view, so a
3015
+ reader sees it and can copy both. That is the named exception the check
3016
+ allows, and the only one in this demo. */
3017
+
3018
+ /* The whole recipe, as an application writes it.
3019
+
3020
+ The plan lives in the application's state. The schedule draws it, a drag
3021
+ reports intents, and the application applies them - here, if asked, with the
3022
+ cascade `ripple` computes over the successors. The findings below come from
3023
+ the same data and change with every drop.
3024
+
3025
+ A right-click opens `ContextMenu` from @umriss-ui/core at the pointer: the
3026
+ schedule reports the interaction with its target and position and knows
3027
+ nothing of the menu. The entries act on the data the same way a drag does -
3028
+ through intents; moving the whole order is `shiftTask`, one move per stop. */
3029
+
3030
+ const QUARTER = 15 * 60_000;
3031
+
3032
+ type Menu = { interaction: ScheduleInteraction; subtask: Subtask | null };
3033
+
3034
+ type Plan = { steps: readonly Subtask[]; log: readonly string[] };
3035
+
3036
+ const START: Plan = { steps: STEPS, log: [] };
3037
+
3038
+ /* One drop can report two intents - a move and a lane - one after the other.
3039
+ Each is applied to the plan as the previous one left it: a functional update,
3040
+ not the plan this render happened to see. */
3041
+ function applied(plan: Plan, intent: Intent, cascade: boolean): Plan {
3042
+ const pushed = cascade ? ripple(plan.steps, MOVES, intent) : [];
3043
+ const steps = [intent, ...pushed].reduce((data, change) => data.map((step) => applyIntent(step, change)), plan.steps);
3044
+ const subject = intent.kind === "place" ? intent.item : intent.subtask;
3045
+ const line = `${intent.kind} ${subject}${pushed.length > 0 ? `, pushed ${pushed.length}` : ""}`;
3046
+ return { steps, log: [line, ...plan.log].slice(0, 4) };
3047
+ }
3048
+
3049
+ export default function Demonstration() {
3050
+ const [plan, setPlan] = useState<Plan>(START);
3051
+ const [cascade, setCascade] = useState(true);
3052
+ const [menu, setMenu] = useState<Menu | null>(null);
3053
+
3054
+ const apply = (intent: Intent) => setPlan((current) => applied(current, intent, cascade));
3055
+
3056
+ const onInteraction = (interaction: ScheduleInteraction) => {
3057
+ if (interaction.type !== "contextmenu") return;
3058
+ const subtask = interaction.hit.kind === "subtask" ? interaction.hit.subtask : null;
3059
+ setMenu({ interaction, subtask });
3060
+ };
3061
+
3062
+ const found = findings(plan.steps, MOVES);
3063
+ const target = menu?.subtask ?? null;
3064
+
3065
+ return (
3066
+ <Stack gap={3}>
3067
+ <Checkbox label="Push successors when a transport no longer fits" checked={cascade} onChange={(e) => setCascade(e.target.checked)} />
3068
+ <Schedule
3069
+ ariaLabel="Plan of Tuesday, 17 March, in the planner's hands"
3070
+ initialDomain={DAY_OF_PLAN}
3071
+ height={380}
3072
+ intents={["move", "lane", "stretch", "setup", "teardown"]}
3073
+ onIntent={apply}
3074
+ onInteraction={onInteraction}
3075
+ >
3076
+ {STATIONS.map((station) => (
3077
+ <Lane key={station.id} id={station.id} label={station.label} />
3078
+ ))}
3079
+ <Transports data={MOVES} />
3080
+ <Subtasks data={plan.steps} tasks={ORDERS} />
3081
+ </Schedule>
3082
+ <ContextMenu
3083
+ open={menu !== null}
3084
+ position={{ x: menu?.interaction.clientX ?? 0, y: menu?.interaction.clientY ?? 0 }}
3085
+ onOpenChange={(open) => {
3086
+ if (!open) setMenu(null);
3087
+ }}
3088
+ ariaLabel={target !== null ? `Actions for ${target.id}` : "Actions for the plan"}
3089
+ >
3090
+ {target !== null ? (
3091
+ <>
3092
+ <MenuItem onSelect={() => apply({ kind: "move", subtask: target.id, from: target.from - QUARTER, to: target.to - QUARTER })}>
3093
+ Earlier by a quarter hour
3094
+ </MenuItem>
3095
+ <MenuItem onSelect={() => apply({ kind: "move", subtask: target.id, from: target.from + QUARTER, to: target.to + QUARTER })}>
3096
+ Later by a quarter hour
3097
+ </MenuItem>
3098
+ <MenuItem onSelect={() => shiftTask(plan.steps, target.task, QUARTER).forEach(apply)}>
3099
+ The whole order later by a quarter hour
3100
+ </MenuItem>
3101
+ <MenuSeparator />
3102
+ <MenuItem onSelect={() => apply({ kind: "setup", subtask: target.id, setup: 0 })} disabled={(target.setup ?? 0) === 0}>
3103
+ Remove the setup
3104
+ </MenuItem>
3105
+ </>
3106
+ ) : (
3107
+ <MenuItem onSelect={() => setPlan(START)}>Reset the plan</MenuItem>
3108
+ )}
3109
+ </ContextMenu>
3110
+ <Text size="sm" tone="secondary" data-findings-summary>
3111
+ {found.overlaps.length} {found.overlaps.length === 1 ? "overlap" : "overlaps"}, {found.lateTransports.length}{" "}
3112
+ {found.lateTransports.length === 1 ? "late transport" : "late transports"}
3113
+ </Text>
3114
+ <Text size="xs" mono tone="muted" data-intent-log>
3115
+ {plan.log.length === 0 ? "Drag a subtask, or right-click one" : plan.log.join(" · ")}
3116
+ </Text>
3117
+ </Stack>
3118
+ );
3119
+ }
3120
+ ```
3121
+
3122
+ It imports `data.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
3123
+
3124
+ ## Findings
3125
+
3126
+ What a planner opens a schedule for, as data beside the picture.
3127
+
3128
+ ### Findings as data
3129
+
3130
+ Overlaps on a lane and late transports, computed from the data - to list, count and act on.
3131
+
3132
+ ```ts
3133
+ import { findings, overlaps, lateTransports } from "@umriss-ui/schedule";
3134
+ ```
3135
+
3136
+ Demo page: https://romanhaendler.github.io/umriss-ui/schedule/#/findings
3137
+
3138
+ #### Examples
3139
+
3140
+ ##### The findings beside the picture
3141
+
3142
+ ```tsx
3143
+ import { Stack, Text } from "@umriss-ui/core";
3144
+ import { Lane, Schedule, Subtasks, Transports, findings } from "@umriss-ui/schedule";
3145
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
3146
+
3147
+ /* What the schedule draws, `findings` returns: every overlap with its lane, the
3148
+ two subtasks and the time they share, and every late transport with its
3149
+ departure, its arrival and how much time is missing. It is a pure function of
3150
+ the same data - a list, a count on a tile, a filter for "everything that does
3151
+ not work" is one call away. `overlaps` and `lateTransports` are the two
3152
+ halves on their own. */
3153
+
3154
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
3155
+ const min = (n: number) => n * 60_000;
3156
+
3157
+ const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
3158
+
3159
+
3160
+ const STATIONS = [
3161
+ { id: "mill", label: "Mill" },
3162
+ { id: "press", label: "Press 2" },
3163
+ { id: "paint", label: "Paint shop" },
3164
+ ];
3165
+
3166
+ const ORDERS: readonly Task[] = [
3167
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
3168
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
3169
+ ];
3170
+
3171
+ const STEPS: readonly Subtask[] = [
3172
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
3173
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
3174
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
3175
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
3176
+ ];
3177
+
3178
+ const MOVES: readonly Transport[] = [
3179
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
3180
+ /* Leaves the mill at 11:30 and has ten minutes to reach the paint shop's
3181
+ setup at 11:40 - it takes twenty-five. A late transport, on purpose. */
3182
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
3183
+ ];
3184
+
3185
+ const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
3186
+
3187
+ export default function AsData() {
3188
+ const found = findings(STEPS, MOVES);
3189
+
3190
+ return (
3191
+ <Stack gap={3}>
3192
+ <Schedule ariaLabel="Plan of Tuesday, 17 March" initialDomain={DAY_OF_PLAN} height={196}>
3193
+ {STATIONS.map((station) => (
3194
+ <Lane key={station.id} id={station.id} label={station.label} />
3195
+ ))}
3196
+ <Transports data={MOVES} />
3197
+ <Subtasks data={STEPS} tasks={ORDERS} />
3198
+ </Schedule>
3199
+ <ul style={{ margin: 0, paddingLeft: 18 }}>
3200
+ {found.overlaps.map((o) => (
3201
+ <Text as="li" size="sm" key={`${o.first}/${o.second}`}>
3202
+ Overlap on {o.lane}: {o.first} and {o.second}, {time(o.from)}–{time(o.to)}
3203
+ </Text>
3204
+ ))}
3205
+ {found.lateTransports.map((l) => (
3206
+ <Text as="li" size="sm" key={l.transport}>
3207
+ Late transport {l.transport}: {Math.round(l.shortBy / 60_000)} minutes short
3208
+ </Text>
3209
+ ))}
3210
+ </ul>
3211
+ </Stack>
3212
+ );
3213
+ }
3214
+ ```
3215
+
3216
+ #### API
3217
+
3218
+ ##### `Overlap`
3219
+
3220
+ | Prop | Type | Default | Description |
3221
+ |---|---|---|---|
3222
+ | `lane` (required) | `string` | — | The lane both sit on. |
3223
+ | `first` (required) | `string` | — | The subtask that starts earlier (setup included); on a tie, the one that stands first in the data. |
3224
+ | `second` (required) | `string` | — | The other one. |
3225
+ | `from` (required) | `number` | — | Where the covering begins. |
3226
+ | `to` (required) | `number` | — | Where it ends. |
3227
+
3228
+ ##### `LateTransport`
3229
+
3230
+ | Prop | Type | Default | Description |
3231
+ |---|---|---|---|
3232
+ | `transport` (required) | `string` | — | The id of the transport. |
3233
+ | `departure` (required) | `number` | — | When it leaves, by its anchor. |
3234
+ | `arrival` (required) | `number` | — | When it has to have arrived, by its anchor. |
3235
+ | `shortBy` (required) | `number` | — | How much time is missing: departure plus duration, minus arrival. |
3236
+
3237
+ ## The rest of the API
3238
+
3239
+ Exported as well, and named on no page above: the pure modules behind the components, and the types they are made of. Each with its declaration as the package's `.d.ts` carries it.
3240
+
3241
+ ### `occupied`
3242
+
3243
+ ```ts
3244
+ /** The interval a subtask occupies on its lane: setup and teardown included. */
3245
+ function occupied(subtask: Subtask): {
3246
+ from: number;
3247
+ to: number;
3248
+ };
3249
+ ```
3250
+
3251
+ ### `LaneIntent`
3252
+
3253
+ ```ts
3254
+ /** Put the subtask on another lane. */
3255
+ interface LaneIntent {
3256
+ readonly kind: "lane";
3257
+ /** The id of the subtask. */
3258
+ readonly subtask: string;
3259
+ /** The id of the new lane. */
3260
+ readonly lane: string;
3261
+ }
3262
+ ```
3263
+
3264
+ ### `SetupIntent`
3265
+
3266
+ ```ts
3267
+ /** Change the setup on its own. */
3268
+ interface SetupIntent {
3269
+ readonly kind: "setup";
3270
+ /** The id of the subtask. */
3271
+ readonly subtask: string;
3272
+ /** The new duration of the setup. */
3273
+ readonly setup: number;
3274
+ }
3275
+ ```
3276
+
3277
+ ### `StretchIntent`
3278
+
3279
+ ```ts
3280
+ /** Stretch the main time at one or both of its edges. */
3281
+ interface StretchIntent {
3282
+ readonly kind: "stretch";
3283
+ /** The id of the subtask. */
3284
+ readonly subtask: string;
3285
+ /** The new start of the main time. */
3286
+ readonly from: number;
3287
+ /** The new end of the main time. */
3288
+ readonly to: number;
3289
+ }
3290
+ ```
3291
+
3292
+ ### `TeardownIntent`
3293
+
3294
+ ```ts
3295
+ /** Change the teardown on its own. */
3296
+ interface TeardownIntent {
3297
+ readonly kind: "teardown";
3298
+ /** The id of the subtask. */
3299
+ readonly subtask: string;
3300
+ /** The new duration of the teardown. */
3301
+ readonly teardown: number;
3302
+ }
3303
+ ```
3304
+
3305
+ ### `Findings`
3306
+
3307
+ ```ts
3308
+ /** Both kinds of finding over the same data. */
3309
+ interface Findings {
3310
+ readonly overlaps: readonly Overlap[];
3311
+ readonly lateTransports: readonly LateTransport[];
3312
+ }
3313
+ ```
3314
+
3315
+ ### `PlaceIntent`
3316
+
3317
+ ```ts
3318
+ /** Put work on the plan that was not on it: the answer to a drag from outside
3319
+ the schedule (schedule-refinement 07). It carries no subtask id - there is
3320
+ no subtask yet; the caller creates one, with its own identity, from
3321
+ `subtaskFromPlace`. */
3322
+ interface PlaceIntent {
3323
+ readonly kind: "place";
3324
+ /** The caller's key for the dragged item, as it declared it while dragging. */
3325
+ readonly item: string;
3326
+ /** The task the work belongs to. */
3327
+ readonly task: string;
3328
+ /** The lane it was dropped on. */
3329
+ readonly lane: string;
3330
+ /** Where its main time would start. */
3331
+ readonly from: number;
3332
+ /** Where its main time would end. */
3333
+ readonly to: number;
3334
+ /** The setup it was declared with. */
3335
+ readonly setup?: number;
3336
+ /** The teardown it was declared with. */
3337
+ readonly teardown?: number;
3338
+ }
3339
+ ```
3340
+
3341
+ ### `ResolvedAppearance`
3342
+
3343
+ ```ts
3344
+ /** What the drawing needs to know, with the contradiction already settled.
3345
+
3346
+ The four names are the ones this module was born with, and they are kept so
3347
+ that a caller's code does not change because a picture did. Two of them are
3348
+ now named after a drawing they no longer use, and the channel each one
3349
+ really owns stands beside it (`sceneDraw.ts`, "ONE CHANNEL PER
3350
+ STATEMENT"). */
3351
+ interface ResolvedAppearance {
3352
+ /** `provisional`: the FILL - none at all, so the surface shows through, with
3353
+ a dashed outline in the task colour. Planned, not released. */
3354
+ readonly dashed: boolean;
3355
+ /** `fixed`: the ENDS - a cap at each end of the main time. Not a hatch any
3356
+ more; a hatch now says "not available" of a refused lane, and the name is
3357
+ kept only so that this type did not change with the picture. */
3358
+ readonly hatched: boolean;
3359
+ /** `muted`: the SATURATION - the task colour mixed half into the surface,
3360
+ opaque and at full height. Another shift's, another crew's. */
3361
+ readonly muted: boolean;
3362
+ /** `open`: the FADE, where the bar passes the edge of the view. It goes on
3363
+ past what is drawn. */
3364
+ readonly open: boolean;
3365
+ }
3366
+ ```
3367
+
3368
+ ## Files the examples show
3369
+
3370
+ The demo's own data, which some examples import from beside themselves. Copied with the example, it runs.
3371
+
3372
+ ### `data.ts`
3373
+
3374
+ ```ts
3375
+ /* The plant of the demo: seven stations, six orders on their way through them
3376
+ on Tuesday, 17 March 2026 - the day the browser suites freeze the clock on.
3377
+
3378
+ Deterministic and written out, not generated from a seed: a planner reads
3379
+ these as a day's plan, and a finding is placed where it is on purpose - the
3380
+ overlap on the mill, the late transport into the paint shop. */
3381
+
3382
+ import type { Subtask, Task, Transport } from "@umriss-ui/schedule";
3383
+
3384
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
3385
+ const min = (n: number) => n * 60_000;
3386
+
3387
+ export const DAY_OF_PLAN: readonly [number, number] = [at(5, 30), at(18)];
3388
+
3389
+ export const STATIONS = [
3390
+ { id: "saw", label: "Saw 1" },
3391
+ { id: "lathe-1", label: "Lathe 1" },
3392
+ { id: "lathe-2", label: "Lathe 2" },
3393
+ { id: "mill", label: "Mill" },
3394
+ { id: "press", label: "Press 2" },
3395
+ { id: "paint", label: "Paint shop" },
3396
+ { id: "qa", label: "Inspection" },
3397
+ ] as const;
3398
+
3399
+ /* Colours in both schemes, as the application's own tokens would give them. */
3400
+ export const ORDERS: readonly Task[] = [
3401
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
3402
+ { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
3403
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
3404
+ { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
3405
+ { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
3406
+ { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
3407
+ ];
3408
+
3409
+ export const STEPS: readonly Subtask[] = [
3410
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: at(6), to: at(7), setup: min(15), teardown: min(10) },
3411
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: at(8), to: at(10, 30), setup: min(30), teardown: min(15) },
3412
+ { id: "a-2041-3", task: "a-2041", lane: "qa", from: at(11, 30), to: at(12, 15) },
3413
+
3414
+ { id: "a-2042-1", task: "a-2042", lane: "saw", from: at(7, 30), to: at(8, 15), setup: min(10) },
3415
+ { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: at(9), to: at(11), setup: min(20), teardown: min(15) },
3416
+ { id: "a-2042-3", task: "a-2042", lane: "qa", from: at(13), to: at(13, 30) },
3417
+
3418
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: at(6, 30), to: at(8), setup: min(30), teardown: min(15) },
3419
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: at(10), to: at(11, 30), setup: min(15) },
3420
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: at(12), to: at(14), setup: min(20), teardown: min(20) },
3421
+
3422
+ { id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: at(6), to: at(8, 30), setup: min(20), teardown: min(10) },
3423
+ { id: "a-2044-2", task: "a-2044", lane: "press", from: at(9, 30), to: at(10, 45), setup: min(25) },
3424
+ { id: "a-2044-3", task: "a-2044", lane: "paint", from: at(14, 45), to: at(16), setup: min(15), teardown: min(15) },
3425
+
3426
+ { id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: at(12), to: at(13, 30), setup: min(15), teardown: min(10) },
3427
+ { id: "a-2045-2", task: "a-2045", lane: "press", from: at(14, 15), to: at(15), setup: min(20) },
3428
+ { id: "a-2045-3", task: "a-2045", lane: "qa", from: at(15, 45), to: at(16, 30) },
3429
+
3430
+ { id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: at(10), to: at(12), setup: min(20), teardown: min(15) },
3431
+ { id: "a-2046-2", task: "a-2046", lane: "mill", from: at(13), to: at(14, 30), setup: min(20), teardown: min(10) },
3432
+ { id: "a-2046-3", task: "a-2046", lane: "qa", from: at(15), to: at(15, 30) },
3433
+ ];
3434
+
3435
+ export const MOVES: readonly Transport[] = [
3436
+ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", duration: min(10) },
3437
+ { id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", duration: min(20) },
3438
+ { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", duration: min(15) },
3439
+ { id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", duration: min(30), leaves: "main" },
3440
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", duration: min(45) },
3441
+ /* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
3442
+ setup at 11:40 - it takes 25: a late transport, on purpose. */
3443
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", duration: min(25) },
3444
+ { id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", duration: min(20) },
3445
+ { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", duration: min(60), arrives: "main" },
3446
+ { id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", duration: min(15) },
3447
+ { id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", duration: min(15) },
3448
+ { id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", duration: min(20) },
3449
+ { id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", duration: min(10), leaves: "main" },
3450
+ ];
3451
+ ```