@umriss-ui/schedule 0.3.12 → 0.3.13
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.
- package/CHANGELOG.md +6 -0
- package/dist/schedule.css +1 -1
- package/dist/schedule.js +1 -1
- package/docs/llms-full.md +1359 -1534
- package/package.json +3 -3
package/docs/llms-full.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @umriss-ui/schedule 0.3.
|
|
1
|
+
# @umriss-ui/schedule 0.3.13
|
|
2
2
|
|
|
3
3
|
> React Gantt-style schedule for data-dense applications - work on lanes over time, with dependencies, blocked time, findings and controlled editing.
|
|
4
4
|
|
|
@@ -24,7 +24,51 @@ import { useState } from "react";
|
|
|
24
24
|
import { Alert, Badge, Stack, Text } from "@umriss-ui/core";
|
|
25
25
|
import { BlockedTimes, Lane, Schedule, Subtasks, applyIntent, findings } from "@umriss-ui/schedule";
|
|
26
26
|
import type { BlockedTime, Intent, Subtask, Task } from "@umriss-ui/schedule";
|
|
27
|
-
|
|
27
|
+
|
|
28
|
+
/* Data from the operations world, written out here so the example runs on its own. */
|
|
29
|
+
|
|
30
|
+
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
31
|
+
|
|
32
|
+
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
33
|
+
const NOW = at(17, 10, 30);
|
|
34
|
+
|
|
35
|
+
interface Engineer {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
team: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ENGINEERS: readonly Engineer[] = [
|
|
42
|
+
{ id: "priya", name: "Priya Raman", team: "Payments" },
|
|
43
|
+
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
|
|
44
|
+
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
|
|
45
|
+
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
|
|
46
|
+
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
|
|
47
|
+
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
|
|
48
|
+
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
|
|
49
|
+
{ id: "felix", name: "Felix Brandt", team: "Insights" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
interface OnCall {
|
|
53
|
+
id: string;
|
|
54
|
+
engineer: string;
|
|
55
|
+
rotation: "primary" | "secondary";
|
|
56
|
+
from: number;
|
|
57
|
+
to: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
|
|
61
|
+
secondary of one day is the primary of the next. */
|
|
62
|
+
const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
|
|
63
|
+
{ rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
|
|
64
|
+
{ rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
|
|
65
|
+
].map(({ rotation, engineer }) => ({
|
|
66
|
+
id: `${rotation}-${16 + day}`,
|
|
67
|
+
engineer,
|
|
68
|
+
rotation,
|
|
69
|
+
from: at(16 + day, 9),
|
|
70
|
+
to: at(17 + day, 9),
|
|
71
|
+
}))).flat();
|
|
28
72
|
|
|
29
73
|
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
30
74
|
|
|
@@ -137,8 +181,6 @@ export default function HandOver() {
|
|
|
137
181
|
}
|
|
138
182
|
```
|
|
139
183
|
|
|
140
|
-
It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
141
|
-
|
|
142
184
|
### Plan tomorrow's tours
|
|
143
185
|
|
|
144
186
|
A dispatcher lays out tomorrow's delivery tours van by van and moves them until every stop arrives inside the window its customer was promised.
|
|
@@ -155,15 +197,143 @@ import { useState } from "react";
|
|
|
155
197
|
import { Badge, Button, Stack, Text } from "@umriss-ui/core";
|
|
156
198
|
import { Dependencies, Lane, LaneGroup, Schedule, Subtasks, applyIntent, ripple, shiftTask } from "@umriss-ui/schedule";
|
|
157
199
|
import type { Dependency, Intent, Subtask, Task } from "@umriss-ui/schedule";
|
|
158
|
-
import { DEPOTS, DRIVERS, TOURS, VEHICLES } from "./logistics";
|
|
159
200
|
|
|
201
|
+
/* Data from the logistics world, written out here so the example runs on its own. */
|
|
202
|
+
|
|
203
|
+
/** A small LCG - the same numbers on every computer. */
|
|
204
|
+
function random(seed: number): () => number {
|
|
205
|
+
let s = seed >>> 0;
|
|
206
|
+
return () => {
|
|
207
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
208
|
+
return s / 4294967296;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
160
213
|
const MINUTE = 60_000;
|
|
214
|
+
|
|
215
|
+
const DEPOTS = [
|
|
216
|
+
{ id: "north", name: "North depot" },
|
|
217
|
+
{ id: "river", name: "Riverside depot" },
|
|
218
|
+
{ id: "east", name: "East Gate depot" },
|
|
219
|
+
] as const;
|
|
220
|
+
|
|
221
|
+
interface Vehicle {
|
|
222
|
+
id: string;
|
|
223
|
+
plate: string;
|
|
224
|
+
type: "van" | "e-van" | "truck";
|
|
225
|
+
/** Payload, in kg. */
|
|
226
|
+
capacity: number;
|
|
227
|
+
depot: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const VEHICLES: readonly Vehicle[] = [
|
|
231
|
+
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
|
|
232
|
+
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
|
|
233
|
+
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
|
|
234
|
+
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
|
|
235
|
+
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
|
|
236
|
+
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
|
|
237
|
+
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
|
|
238
|
+
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const DRIVERS = [
|
|
242
|
+
{ id: "d1", name: "Martin Hale", depot: "north" },
|
|
243
|
+
{ id: "d2", name: "Nadia Petrova", depot: "north" },
|
|
244
|
+
{ id: "d3", name: "Owen Carter", depot: "north" },
|
|
245
|
+
{ id: "d4", name: "Lucia Romero", depot: "river" },
|
|
246
|
+
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
|
|
247
|
+
{ id: "d6", name: "Hanna Berg", depot: "east" },
|
|
248
|
+
{ id: "d7", name: "Yusuf Demir", depot: "east" },
|
|
249
|
+
{ id: "d8", name: "Clara Wendt", depot: "east" },
|
|
250
|
+
] as const;
|
|
251
|
+
|
|
252
|
+
const CUSTOMERS = [
|
|
253
|
+
"Holloway Garden Supplies",
|
|
254
|
+
"Marlow & Finch Books",
|
|
255
|
+
"Oakridge Pharmacy",
|
|
256
|
+
"Brixley Cycles",
|
|
257
|
+
"Tamsin's Bakery",
|
|
258
|
+
"Northfold Office",
|
|
259
|
+
"Pellham Hardware",
|
|
260
|
+
"Greywick Studio",
|
|
261
|
+
"Juniper Lane Florist",
|
|
262
|
+
"Ashcombe Dental",
|
|
263
|
+
] as const;
|
|
264
|
+
|
|
265
|
+
interface Stop {
|
|
266
|
+
id: string;
|
|
267
|
+
shipment: string;
|
|
268
|
+
customer: string;
|
|
269
|
+
/** The window the customer was promised, `[from, to]`. */
|
|
270
|
+
window: readonly [number, number];
|
|
271
|
+
/** When the tour plans to be there. */
|
|
272
|
+
arrival: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface Tour {
|
|
276
|
+
id: string;
|
|
277
|
+
vehicle: string;
|
|
278
|
+
driver: string;
|
|
279
|
+
depot: string;
|
|
280
|
+
/** Leaves the depot, loaded; back at the depot. */
|
|
281
|
+
from: number;
|
|
282
|
+
to: number;
|
|
283
|
+
/** Minutes of loading before `from`. */
|
|
284
|
+
loading: number;
|
|
285
|
+
km: number;
|
|
286
|
+
stops: readonly Stop[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
|
|
290
|
+
window; every seventh arrival misses its window - the late ones a
|
|
291
|
+
dispatcher looks for. */
|
|
292
|
+
function tour(index: number): Tour {
|
|
293
|
+
const r = random(700 + index);
|
|
294
|
+
const vehicle = VEHICLES[index]!;
|
|
295
|
+
const truck = vehicle.type === "truck";
|
|
296
|
+
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
|
|
297
|
+
const count = truck ? 5 : 8 + Math.floor(r() * 4);
|
|
298
|
+
const stops: Stop[] = [];
|
|
299
|
+
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
|
|
300
|
+
for (let i = 0; i < count; i++) {
|
|
301
|
+
const n = index * 20 + i;
|
|
302
|
+
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
|
|
303
|
+
/* A late stop's window closed half an hour or more before the arrival. */
|
|
304
|
+
const late = i >= 2 && n % 7 === 3;
|
|
305
|
+
const halfHour = 30 * MINUTE;
|
|
306
|
+
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
|
|
307
|
+
stops.push({
|
|
308
|
+
id: `s-${index + 1}-${i + 1}`,
|
|
309
|
+
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
|
|
310
|
+
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
|
|
311
|
+
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
|
|
312
|
+
arrival,
|
|
313
|
+
});
|
|
314
|
+
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
id: `T-${String(index + 1).padStart(2, "0")}`,
|
|
318
|
+
vehicle: vehicle.id,
|
|
319
|
+
driver: DRIVERS[index]!.id,
|
|
320
|
+
depot: vehicle.depot,
|
|
321
|
+
from,
|
|
322
|
+
to: arrival + 30 * MINUTE,
|
|
323
|
+
loading: truck ? 45 : 30,
|
|
324
|
+
km: Math.round((truck ? 140 : 70) + r() * 40),
|
|
325
|
+
stops,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
|
|
330
|
+
|
|
161
331
|
const DAY = 24 * 60 * MINUTE;
|
|
162
332
|
/* Today's tours in the world, laid on tomorrow. */
|
|
163
333
|
const DWELL = 10 * MINUTE;
|
|
164
334
|
const QUARTER = 15 * MINUTE;
|
|
165
335
|
|
|
166
|
-
const
|
|
336
|
+
const tomorrow = (hours: number, minutes = 0) => new Date(2026, 2, 18, hours, minutes).getTime();
|
|
167
337
|
|
|
168
338
|
const COLORS = [
|
|
169
339
|
"light-dark(#2563eb, #6b9bff)",
|
|
@@ -248,7 +418,7 @@ export default function TourPlan() {
|
|
|
248
418
|
<div data-callout="1">
|
|
249
419
|
<Schedule
|
|
250
420
|
ariaLabel="Tours of Wednesday, 18 March"
|
|
251
|
-
initialDomain={[
|
|
421
|
+
initialDomain={[tomorrow(5, 30), tomorrow(15)]}
|
|
252
422
|
height={470}
|
|
253
423
|
intents={["move"]}
|
|
254
424
|
onIntent={apply}
|
|
@@ -295,8 +465,6 @@ export default function TourPlan() {
|
|
|
295
465
|
}
|
|
296
466
|
```
|
|
297
467
|
|
|
298
|
-
It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
299
|
-
|
|
300
468
|
### Plan the sprint around leave
|
|
301
469
|
|
|
302
470
|
A team lead checks the running sprint against holidays, sick days and training, and moves the work that lands on someone who is away.
|
|
@@ -313,56 +481,169 @@ import { useState } from "react";
|
|
|
313
481
|
import { Alert, Badge, Stack, Text } from "@umriss-ui/core";
|
|
314
482
|
import { BlockedTimes, Lane, LaneGroup, Schedule, Subtasks, applyIntent, findings } from "@umriss-ui/schedule";
|
|
315
483
|
import type { BlockedTime, Intent, Subtask } from "@umriss-ui/schedule";
|
|
316
|
-
import { LEAVE, NOW, PEOPLE, PROJECTS, SPRINTS, WORK } from "./planning";
|
|
317
484
|
|
|
318
|
-
|
|
485
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
319
486
|
|
|
320
|
-
const
|
|
487
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
321
488
|
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
from: at(day, 8),
|
|
325
|
-
to: at(day, 18),
|
|
326
|
-
}));
|
|
489
|
+
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
490
|
+
const NOW = at(17, 10, 30);
|
|
327
491
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
},
|
|
492
|
+
interface Person {
|
|
493
|
+
id: string;
|
|
494
|
+
name: string;
|
|
495
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
496
|
+
team: "Web" | "Apps";
|
|
497
|
+
/** Hours a week they can be planned for. */
|
|
498
|
+
capacity: number;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const PEOPLE: readonly Person[] = [
|
|
502
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
503
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
504
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
505
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
506
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
507
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
508
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
509
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
510
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
511
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
339
512
|
];
|
|
340
513
|
|
|
341
|
-
|
|
342
|
-
id:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}));
|
|
514
|
+
interface Project {
|
|
515
|
+
id: string;
|
|
516
|
+
name: string;
|
|
517
|
+
client: string;
|
|
518
|
+
color: string;
|
|
519
|
+
}
|
|
348
520
|
|
|
349
|
-
const
|
|
350
|
-
|
|
521
|
+
const PROJECTS: readonly Project[] = [
|
|
522
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
523
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
524
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
525
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
526
|
+
];
|
|
351
527
|
|
|
352
|
-
|
|
353
|
-
|
|
528
|
+
interface Sprint {
|
|
529
|
+
id: string;
|
|
530
|
+
name: string;
|
|
531
|
+
from: number;
|
|
532
|
+
to: number;
|
|
533
|
+
goal: string;
|
|
534
|
+
}
|
|
354
535
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
536
|
+
/** Two weeks each, Monday 09:00 to the second Friday 17:00. */
|
|
537
|
+
const SPRINTS: readonly Sprint[] = [
|
|
538
|
+
{ name: "Sprint 12", day: 9 - 28, goal: "Shop checkout on the new design" },
|
|
539
|
+
{ name: "Sprint 13", day: 9 - 14, goal: "Booking app in the stores' beta" },
|
|
540
|
+
{ name: "Sprint 14", day: 9, goal: "Member portal sign-in and profile" },
|
|
541
|
+
{ name: "Sprint 15", day: 23, goal: "Shop search and filters" },
|
|
542
|
+
{ name: "Sprint 16", day: 37, goal: "Booking reminders" },
|
|
543
|
+
].map(({ name, day, goal }) => ({ id: name.toLowerCase().replace(" ", "-"), name, from: at(day), to: at(day + 11, 17), goal }));
|
|
359
544
|
|
|
360
|
-
|
|
545
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
546
|
+
interface WorkItem {
|
|
547
|
+
id: string;
|
|
548
|
+
/** The project. */
|
|
549
|
+
task: string;
|
|
550
|
+
/** The person. */
|
|
551
|
+
lane: string;
|
|
552
|
+
from: number;
|
|
553
|
+
to: number;
|
|
554
|
+
name: string;
|
|
555
|
+
sprint: string;
|
|
556
|
+
/** Hours, as estimated at planning. */
|
|
557
|
+
estimate: number;
|
|
558
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
559
|
+
}
|
|
361
560
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
561
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
562
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
563
|
+
|
|
564
|
+
const WORK: readonly WorkItem[] = [
|
|
565
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
566
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
567
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
568
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
569
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
570
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
571
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
572
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
573
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
574
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
575
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
576
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
577
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
578
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
579
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
580
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
581
|
+
];
|
|
582
|
+
|
|
583
|
+
interface Leave {
|
|
584
|
+
id: string;
|
|
585
|
+
person: string;
|
|
586
|
+
from: number;
|
|
587
|
+
to: number;
|
|
588
|
+
reason: "Holiday" | "Sick" | "Training";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** When people are away: blocked time on their lane. */
|
|
592
|
+
const LEAVE: readonly Leave[] = [
|
|
593
|
+
{ id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
|
|
594
|
+
{ id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
|
|
595
|
+
{ id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
|
|
596
|
+
{ id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
|
|
597
|
+
{ id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
|
|
598
|
+
{ id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
|
|
599
|
+
];
|
|
600
|
+
|
|
601
|
+
const SPRINT = SPRINTS.find((one) => one.id === "sprint-14")!;
|
|
602
|
+
|
|
603
|
+
/* The working days of the sprint: the axis shows these and nothing else. */
|
|
604
|
+
const WORKING_DAYS = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20].map((day) => ({
|
|
605
|
+
from: at(day, 8),
|
|
606
|
+
to: at(day, 18),
|
|
607
|
+
}));
|
|
608
|
+
|
|
609
|
+
/* Added after planning, before anyone looked at the leave table. */
|
|
610
|
+
const START: readonly Subtask[] = [
|
|
611
|
+
...WORK,
|
|
612
|
+
{
|
|
613
|
+
id: "w-117",
|
|
614
|
+
task: "portal",
|
|
615
|
+
lane: "arjun",
|
|
616
|
+
from: at(18),
|
|
617
|
+
to: at(19, 17),
|
|
618
|
+
name: "Account deletion",
|
|
619
|
+
},
|
|
620
|
+
];
|
|
621
|
+
|
|
622
|
+
const BLOCKED: readonly BlockedTime[] = LEAVE.map((leave) => ({
|
|
623
|
+
id: leave.id,
|
|
624
|
+
lane: leave.person,
|
|
625
|
+
from: leave.from,
|
|
626
|
+
to: leave.to,
|
|
627
|
+
label: leave.reason,
|
|
628
|
+
}));
|
|
629
|
+
|
|
630
|
+
const teamOf = (person: string) => PEOPLE.find((one) => one.id === person)?.team;
|
|
631
|
+
const nameOf = (person: string) => PEOPLE.find((one) => one.id === person)?.name ?? person;
|
|
632
|
+
|
|
633
|
+
export default function SprintAroundLeave() {
|
|
634
|
+
const [work, setWork] = useState<readonly Subtask[]>(START);
|
|
635
|
+
|
|
636
|
+
const onIntent = (intent: Intent) => {
|
|
637
|
+
if (intent.kind === "place") return;
|
|
638
|
+
setWork((current) => current.map((item) => applyIntent(item, intent)));
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const clashes = findings(work, [], BLOCKED).inBlockedTime;
|
|
642
|
+
|
|
643
|
+
return (
|
|
644
|
+
<Stack gap={3}>
|
|
645
|
+
<Stack direction="row" gap={2} align="center" wrap data-callout="1">
|
|
646
|
+
<Badge tone="accent">{SPRINT.name}</Badge>
|
|
366
647
|
<Text size="sm">{SPRINT.goal}</Text>
|
|
367
648
|
<Text size="sm" tone="secondary">
|
|
368
649
|
9 to 20 March
|
|
@@ -421,8 +702,6 @@ export default function SprintAroundLeave() {
|
|
|
421
702
|
}
|
|
422
703
|
```
|
|
423
704
|
|
|
424
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
425
|
-
|
|
426
705
|
### Replan the day on the line
|
|
427
706
|
|
|
428
707
|
A production planner at the tile works moves orders between machines during the day, lets the cascade follow and reads the findings before committing.
|
|
@@ -438,8 +717,77 @@ Built from: First schedule, Dependencies, Move and lane, Stretch, lead-in, lead-
|
|
|
438
717
|
import { useState } from "react";
|
|
439
718
|
import { Checkbox, ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
|
|
440
719
|
import { Lane, Schedule, Subtasks, Dependencies, applyIntent, findings, ripple, shiftTask } from "@umriss-ui/schedule";
|
|
441
|
-
import type { Intent, ScheduleInteraction, Subtask } from "@umriss-ui/schedule";
|
|
442
|
-
|
|
720
|
+
import type { Dependency, Intent, ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule";
|
|
721
|
+
|
|
722
|
+
/* Data from the plant world, written out here so the example runs on its own. */
|
|
723
|
+
|
|
724
|
+
const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
725
|
+
const min = (n: number) => n * 60_000;
|
|
726
|
+
|
|
727
|
+
const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)];
|
|
728
|
+
|
|
729
|
+
const STATIONS = [
|
|
730
|
+
{ id: "saw", label: "Saw 1" },
|
|
731
|
+
{ id: "lathe-1", label: "Lathe 1" },
|
|
732
|
+
{ id: "lathe-2", label: "Lathe 2" },
|
|
733
|
+
{ id: "mill", label: "Mill" },
|
|
734
|
+
{ id: "press", label: "Press 2" },
|
|
735
|
+
{ id: "paint", label: "Paint shop" },
|
|
736
|
+
{ id: "qa", label: "Inspection" },
|
|
737
|
+
] as const;
|
|
738
|
+
|
|
739
|
+
/* Colours in both schemes, as the application's own tokens would give them. */
|
|
740
|
+
const ORDERS: readonly Task[] = [
|
|
741
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
742
|
+
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
743
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
744
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
745
|
+
{ id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
|
|
746
|
+
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
const STEPS: readonly Subtask[] = [
|
|
750
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) },
|
|
751
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) },
|
|
752
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) },
|
|
753
|
+
|
|
754
|
+
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) },
|
|
755
|
+
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) },
|
|
756
|
+
{ id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) },
|
|
757
|
+
|
|
758
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) },
|
|
759
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) },
|
|
760
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) },
|
|
761
|
+
|
|
762
|
+
{ id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) },
|
|
763
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) },
|
|
764
|
+
{ id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) },
|
|
765
|
+
|
|
766
|
+
{ id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) },
|
|
767
|
+
{ id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) },
|
|
768
|
+
{ id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) },
|
|
769
|
+
|
|
770
|
+
{ id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) },
|
|
771
|
+
{ id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) },
|
|
772
|
+
{ id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) },
|
|
773
|
+
];
|
|
774
|
+
|
|
775
|
+
const MOVES: readonly Dependency[] = [
|
|
776
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) },
|
|
777
|
+
{ id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) },
|
|
778
|
+
{ id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) },
|
|
779
|
+
{ id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" },
|
|
780
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) },
|
|
781
|
+
/* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
|
|
782
|
+
lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */
|
|
783
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) },
|
|
784
|
+
{ id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) },
|
|
785
|
+
{ id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" },
|
|
786
|
+
{ id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) },
|
|
787
|
+
{ id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) },
|
|
788
|
+
{ id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) },
|
|
789
|
+
{ id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" },
|
|
790
|
+
];
|
|
443
791
|
|
|
444
792
|
/* The whole recipe, as an application writes it.
|
|
445
793
|
|
|
@@ -549,8 +897,6 @@ export default function ReplanTheDay() {
|
|
|
549
897
|
}
|
|
550
898
|
```
|
|
551
899
|
|
|
552
|
-
It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
553
|
-
|
|
554
900
|
## Getting started
|
|
555
901
|
|
|
556
902
|
What an application installs and hands over before the first plan.
|
|
@@ -725,7 +1071,77 @@ The full case: seven stations, six orders and their transports, the present mark
|
|
|
725
1071
|
|
|
726
1072
|
```tsx
|
|
727
1073
|
import { Dependencies, Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
728
|
-
import {
|
|
1074
|
+
import type { Dependency, Subtask, Task } from "@umriss-ui/schedule";
|
|
1075
|
+
|
|
1076
|
+
/* Data from the plant world, written out here so the example runs on its own. */
|
|
1077
|
+
|
|
1078
|
+
const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
1079
|
+
const min = (n: number) => n * 60_000;
|
|
1080
|
+
|
|
1081
|
+
const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)];
|
|
1082
|
+
|
|
1083
|
+
const STATIONS = [
|
|
1084
|
+
{ id: "saw", label: "Saw 1" },
|
|
1085
|
+
{ id: "lathe-1", label: "Lathe 1" },
|
|
1086
|
+
{ id: "lathe-2", label: "Lathe 2" },
|
|
1087
|
+
{ id: "mill", label: "Mill" },
|
|
1088
|
+
{ id: "press", label: "Press 2" },
|
|
1089
|
+
{ id: "paint", label: "Paint shop" },
|
|
1090
|
+
{ id: "qa", label: "Inspection" },
|
|
1091
|
+
] as const;
|
|
1092
|
+
|
|
1093
|
+
/* Colours in both schemes, as the application's own tokens would give them. */
|
|
1094
|
+
const ORDERS: readonly Task[] = [
|
|
1095
|
+
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1096
|
+
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
1097
|
+
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
1098
|
+
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
1099
|
+
{ id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
|
|
1100
|
+
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
1101
|
+
];
|
|
1102
|
+
|
|
1103
|
+
const STEPS: readonly Subtask[] = [
|
|
1104
|
+
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) },
|
|
1105
|
+
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) },
|
|
1106
|
+
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) },
|
|
1107
|
+
|
|
1108
|
+
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) },
|
|
1109
|
+
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) },
|
|
1110
|
+
{ id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) },
|
|
1111
|
+
|
|
1112
|
+
{ id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) },
|
|
1113
|
+
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) },
|
|
1114
|
+
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) },
|
|
1115
|
+
|
|
1116
|
+
{ id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) },
|
|
1117
|
+
{ id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) },
|
|
1118
|
+
{ id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) },
|
|
1119
|
+
|
|
1120
|
+
{ id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) },
|
|
1121
|
+
{ id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) },
|
|
1122
|
+
{ id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) },
|
|
1123
|
+
|
|
1124
|
+
{ id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) },
|
|
1125
|
+
{ id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) },
|
|
1126
|
+
{ id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) },
|
|
1127
|
+
];
|
|
1128
|
+
|
|
1129
|
+
const MOVES: readonly Dependency[] = [
|
|
1130
|
+
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) },
|
|
1131
|
+
{ id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) },
|
|
1132
|
+
{ id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) },
|
|
1133
|
+
{ id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" },
|
|
1134
|
+
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) },
|
|
1135
|
+
/* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
|
|
1136
|
+
lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */
|
|
1137
|
+
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) },
|
|
1138
|
+
{ id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) },
|
|
1139
|
+
{ id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" },
|
|
1140
|
+
{ id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) },
|
|
1141
|
+
{ id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) },
|
|
1142
|
+
{ id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) },
|
|
1143
|
+
{ id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" },
|
|
1144
|
+
];
|
|
729
1145
|
|
|
730
1146
|
const NAMES = new Map(ORDERS.map((order) => [order.id, order.name ?? order.id]));
|
|
731
1147
|
const HALF_PAST_TEN = new Date(2026, 2, 17, 10, 30).getTime();
|
|
@@ -749,8 +1165,6 @@ export default function MachinePlan() {
|
|
|
749
1165
|
}
|
|
750
1166
|
```
|
|
751
1167
|
|
|
752
|
-
It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
753
|
-
|
|
754
1168
|
#### Keyboard
|
|
755
1169
|
|
|
756
1170
|
| Key | Action |
|
|
@@ -1302,33 +1716,108 @@ Work items straight from the team's tracker: the person is the lane, the project
|
|
|
1302
1716
|
```tsx
|
|
1303
1717
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
1304
1718
|
import type { Subtask } from "@umriss-ui/schedule";
|
|
1305
|
-
import { PEOPLE, PROJECTS, WORK } from "./planning";
|
|
1306
1719
|
|
|
1307
|
-
|
|
1308
|
-
...item,
|
|
1309
|
-
/* Not started yet: drawn hollow, as planned but not begun. */
|
|
1310
|
-
...(item.status === "to do" ? { appearance: ["provisional"] } : {}),
|
|
1311
|
-
}));
|
|
1720
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
1312
1721
|
|
|
1313
|
-
const
|
|
1722
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
1314
1723
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
</Schedule>
|
|
1323
|
-
);
|
|
1724
|
+
interface Person {
|
|
1725
|
+
id: string;
|
|
1726
|
+
name: string;
|
|
1727
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
1728
|
+
team: "Web" | "Apps";
|
|
1729
|
+
/** Hours a week they can be planned for. */
|
|
1730
|
+
capacity: number;
|
|
1324
1731
|
}
|
|
1325
|
-
```
|
|
1326
1732
|
|
|
1327
|
-
|
|
1733
|
+
const PEOPLE: readonly Person[] = [
|
|
1734
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
1735
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
1736
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
1737
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
1738
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
1739
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
1740
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
1741
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
1742
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
1743
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
1744
|
+
];
|
|
1328
1745
|
|
|
1329
|
-
|
|
1746
|
+
interface Project {
|
|
1747
|
+
id: string;
|
|
1748
|
+
name: string;
|
|
1749
|
+
client: string;
|
|
1750
|
+
color: string;
|
|
1751
|
+
}
|
|
1330
1752
|
|
|
1331
|
-
|
|
1753
|
+
const PROJECTS: readonly Project[] = [
|
|
1754
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
1755
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
1756
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
1757
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
1758
|
+
];
|
|
1759
|
+
|
|
1760
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
1761
|
+
interface WorkItem {
|
|
1762
|
+
id: string;
|
|
1763
|
+
/** The project. */
|
|
1764
|
+
task: string;
|
|
1765
|
+
/** The person. */
|
|
1766
|
+
lane: string;
|
|
1767
|
+
from: number;
|
|
1768
|
+
to: number;
|
|
1769
|
+
name: string;
|
|
1770
|
+
sprint: string;
|
|
1771
|
+
/** Hours, as estimated at planning. */
|
|
1772
|
+
estimate: number;
|
|
1773
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
1777
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
1778
|
+
|
|
1779
|
+
const WORK: readonly WorkItem[] = [
|
|
1780
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
1781
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
1782
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
1783
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
1784
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
1785
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
1786
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
1787
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
1788
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
1789
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
1790
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
1791
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
1792
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
1793
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
1794
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
1795
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
1796
|
+
];
|
|
1797
|
+
|
|
1798
|
+
const SUBTASKS: Subtask[] = WORK.map((item): Subtask => ({
|
|
1799
|
+
...item,
|
|
1800
|
+
/* Not started yet: drawn hollow, as planned but not begun. */
|
|
1801
|
+
...(item.status === "to do" ? { appearance: ["provisional"] } : {}),
|
|
1802
|
+
}));
|
|
1803
|
+
|
|
1804
|
+
const SPRINT_14: readonly [number, number] = [new Date(2026, 2, 9, 6).getTime(), new Date(2026, 2, 21).getTime()];
|
|
1805
|
+
|
|
1806
|
+
export default function ASprint() {
|
|
1807
|
+
return (
|
|
1808
|
+
<Schedule ariaLabel="Sprint 14, 9 to 20 March" initialDomain={SPRINT_14} height={500} label={(s) => s.name ?? s.id}>
|
|
1809
|
+
{PEOPLE.map((person) => (
|
|
1810
|
+
<Lane key={person.id} id={person.id} label={person.name} />
|
|
1811
|
+
))}
|
|
1812
|
+
<Subtasks data={SUBTASKS} tasks={PROJECTS} />
|
|
1813
|
+
</Schedule>
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
```
|
|
1817
|
+
|
|
1818
|
+
#### API
|
|
1819
|
+
|
|
1820
|
+
##### `SubtasksProps`
|
|
1332
1821
|
|
|
1333
1822
|
| Prop | Type | Default | Description |
|
|
1334
1823
|
|---|---|---|---|
|
|
@@ -2263,7 +2752,48 @@ A week of the on-call rota: the day band names each day, and the time band's ste
|
|
|
2263
2752
|
```tsx
|
|
2264
2753
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
2265
2754
|
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
2266
|
-
|
|
2755
|
+
|
|
2756
|
+
/* Data from the operations world, written out here so the example runs on its own. */
|
|
2757
|
+
|
|
2758
|
+
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
2759
|
+
|
|
2760
|
+
interface Engineer {
|
|
2761
|
+
id: string;
|
|
2762
|
+
name: string;
|
|
2763
|
+
team: string;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
const ENGINEERS: readonly Engineer[] = [
|
|
2767
|
+
{ id: "priya", name: "Priya Raman", team: "Payments" },
|
|
2768
|
+
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
|
|
2769
|
+
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
|
|
2770
|
+
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
|
|
2771
|
+
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
|
|
2772
|
+
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
|
|
2773
|
+
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
|
|
2774
|
+
{ id: "felix", name: "Felix Brandt", team: "Insights" },
|
|
2775
|
+
];
|
|
2776
|
+
|
|
2777
|
+
interface OnCall {
|
|
2778
|
+
id: string;
|
|
2779
|
+
engineer: string;
|
|
2780
|
+
rotation: "primary" | "secondary";
|
|
2781
|
+
from: number;
|
|
2782
|
+
to: number;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
|
|
2786
|
+
secondary of one day is the primary of the next. */
|
|
2787
|
+
const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
|
|
2788
|
+
{ rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
|
|
2789
|
+
{ rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
|
|
2790
|
+
].map(({ rotation, engineer }) => ({
|
|
2791
|
+
id: `${rotation}-${16 + day}`,
|
|
2792
|
+
engineer,
|
|
2793
|
+
rotation,
|
|
2794
|
+
from: at(16 + day, 9),
|
|
2795
|
+
to: at(17 + day, 9),
|
|
2796
|
+
}))).flat();
|
|
2267
2797
|
|
|
2268
2798
|
const ROTATIONS: Task[] = [
|
|
2269
2799
|
{ id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" },
|
|
@@ -2286,8 +2816,6 @@ export default function AWeek() {
|
|
|
2286
2816
|
}
|
|
2287
2817
|
```
|
|
2288
2818
|
|
|
2289
|
-
It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
2290
|
-
|
|
2291
2819
|
##### Cut the nights out of the axis
|
|
2292
2820
|
|
|
2293
2821
|
The depot works 06:00 to 22:00: `calendar` lists those hours, the nights leave the axis, and a drag into one stops at the next morning.
|
|
@@ -2344,9 +2872,84 @@ Weekdays 09:00 to 17:00 as the `calendar`: a two-week sprint reads as ten workin
|
|
|
2344
2872
|
|
|
2345
2873
|
```tsx
|
|
2346
2874
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
2347
|
-
import { PEOPLE, PROJECTS, WORK } from "./planning";
|
|
2348
2875
|
|
|
2349
|
-
|
|
2876
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
2877
|
+
|
|
2878
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
2879
|
+
|
|
2880
|
+
interface Person {
|
|
2881
|
+
id: string;
|
|
2882
|
+
name: string;
|
|
2883
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
2884
|
+
team: "Web" | "Apps";
|
|
2885
|
+
/** Hours a week they can be planned for. */
|
|
2886
|
+
capacity: number;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
const PEOPLE: readonly Person[] = [
|
|
2890
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
2891
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
2892
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
2893
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
2894
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
2895
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
2896
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
2897
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
2898
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
2899
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
2900
|
+
];
|
|
2901
|
+
|
|
2902
|
+
interface Project {
|
|
2903
|
+
id: string;
|
|
2904
|
+
name: string;
|
|
2905
|
+
client: string;
|
|
2906
|
+
color: string;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
const PROJECTS: readonly Project[] = [
|
|
2910
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
2911
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
2912
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
2913
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
2914
|
+
];
|
|
2915
|
+
|
|
2916
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
2917
|
+
interface WorkItem {
|
|
2918
|
+
id: string;
|
|
2919
|
+
/** The project. */
|
|
2920
|
+
task: string;
|
|
2921
|
+
/** The person. */
|
|
2922
|
+
lane: string;
|
|
2923
|
+
from: number;
|
|
2924
|
+
to: number;
|
|
2925
|
+
name: string;
|
|
2926
|
+
sprint: string;
|
|
2927
|
+
/** Hours, as estimated at planning. */
|
|
2928
|
+
estimate: number;
|
|
2929
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
2933
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
2934
|
+
|
|
2935
|
+
const WORK: readonly WorkItem[] = [
|
|
2936
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
2937
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
2938
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
2939
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
2940
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
2941
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
2942
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
2943
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
2944
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
2945
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
2946
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
2947
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
2948
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
2949
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
2950
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
2951
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
2952
|
+
];
|
|
2350
2953
|
|
|
2351
2954
|
const WORKING_DAYS = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
|
|
2352
2955
|
const OFFICE_HOURS = WORKING_DAYS.map((day) => ({ from: at(day, 9), to: at(day, 17) }));
|
|
@@ -2372,8 +2975,6 @@ export default function OfficeHours() {
|
|
|
2372
2975
|
}
|
|
2373
2976
|
```
|
|
2374
2977
|
|
|
2375
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
2376
|
-
|
|
2377
2978
|
#### When to use something else
|
|
2378
2979
|
|
|
2379
2980
|
- Time a single lane is not available → Blocked time
|
|
@@ -2540,55 +3141,148 @@ The team's holidays, sick days and training become `BlockedTimes`, and `findings
|
|
|
2540
3141
|
import { Stack, Text } from "@umriss-ui/core";
|
|
2541
3142
|
import { BlockedTimes, Lane, Schedule, Subtasks, findings } from "@umriss-ui/schedule";
|
|
2542
3143
|
import type { BlockedTime } from "@umriss-ui/schedule";
|
|
2543
|
-
import { LEAVE, PEOPLE, PROJECTS, WORK } from "./planning";
|
|
2544
|
-
|
|
2545
|
-
const BLOCKED: BlockedTime[] = LEAVE.map((leave) => ({ id: leave.id, lane: leave.person, from: leave.from, to: leave.to, label: leave.reason }));
|
|
2546
3144
|
|
|
2547
|
-
|
|
3145
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
2548
3146
|
|
|
2549
|
-
const
|
|
2550
|
-
const PERSON = new Map(PEOPLE.map((person) => [person.id, person.name]));
|
|
3147
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
2551
3148
|
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
))}
|
|
2560
|
-
<BlockedTimes data={BLOCKED} />
|
|
2561
|
-
<Subtasks data={WORK} tasks={PROJECTS} />
|
|
2562
|
-
</Schedule>
|
|
2563
|
-
<Text size="sm" tone="secondary">
|
|
2564
|
-
{clashes.length === 0
|
|
2565
|
-
? "No work falls into anyone's leave."
|
|
2566
|
-
: clashes.map((clash) => `${NAME.get(clash.subtask)} (${PERSON.get(WORK.find((w) => w.id === clash.subtask)?.lane ?? "")})`).join(" · ")}
|
|
2567
|
-
</Text>
|
|
2568
|
-
</Stack>
|
|
2569
|
-
);
|
|
3149
|
+
interface Person {
|
|
3150
|
+
id: string;
|
|
3151
|
+
name: string;
|
|
3152
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
3153
|
+
team: "Web" | "Apps";
|
|
3154
|
+
/** Hours a week they can be planned for. */
|
|
3155
|
+
capacity: number;
|
|
2570
3156
|
}
|
|
2571
|
-
```
|
|
2572
|
-
|
|
2573
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
2574
|
-
|
|
2575
|
-
#### When to use something else
|
|
2576
3157
|
|
|
2577
|
-
|
|
3158
|
+
const PEOPLE: readonly Person[] = [
|
|
3159
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
3160
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
3161
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
3162
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
3163
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
3164
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
3165
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
3166
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
3167
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
3168
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
3169
|
+
];
|
|
2578
3170
|
|
|
2579
|
-
|
|
3171
|
+
interface Project {
|
|
3172
|
+
id: string;
|
|
3173
|
+
name: string;
|
|
3174
|
+
client: string;
|
|
3175
|
+
color: string;
|
|
3176
|
+
}
|
|
2580
3177
|
|
|
2581
|
-
|
|
3178
|
+
const PROJECTS: readonly Project[] = [
|
|
3179
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
3180
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
3181
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
3182
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
3183
|
+
];
|
|
2582
3184
|
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
3185
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
3186
|
+
interface WorkItem {
|
|
3187
|
+
id: string;
|
|
3188
|
+
/** The project. */
|
|
3189
|
+
task: string;
|
|
3190
|
+
/** The person. */
|
|
3191
|
+
lane: string;
|
|
3192
|
+
from: number;
|
|
3193
|
+
to: number;
|
|
3194
|
+
name: string;
|
|
3195
|
+
sprint: string;
|
|
3196
|
+
/** Hours, as estimated at planning. */
|
|
3197
|
+
estimate: number;
|
|
3198
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
3199
|
+
}
|
|
2586
3200
|
|
|
2587
|
-
|
|
3201
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
3202
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
2588
3203
|
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
3204
|
+
const WORK: readonly WorkItem[] = [
|
|
3205
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
3206
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
3207
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
3208
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
3209
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
3210
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
3211
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
3212
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
3213
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
3214
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
3215
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
3216
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
3217
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
3218
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
3219
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
3220
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
3221
|
+
];
|
|
3222
|
+
|
|
3223
|
+
interface Leave {
|
|
3224
|
+
id: string;
|
|
3225
|
+
person: string;
|
|
3226
|
+
from: number;
|
|
3227
|
+
to: number;
|
|
3228
|
+
reason: "Holiday" | "Sick" | "Training";
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/** When people are away: blocked time on their lane. */
|
|
3232
|
+
const LEAVE: readonly Leave[] = [
|
|
3233
|
+
{ id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
|
|
3234
|
+
{ id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
|
|
3235
|
+
{ id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
|
|
3236
|
+
{ id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
|
|
3237
|
+
{ id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
|
|
3238
|
+
{ id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
|
|
3239
|
+
];
|
|
3240
|
+
|
|
3241
|
+
const BLOCKED: BlockedTime[] = LEAVE.map((leave) => ({ id: leave.id, lane: leave.person, from: leave.from, to: leave.to, label: leave.reason }));
|
|
3242
|
+
|
|
3243
|
+
const SPRINT_14: readonly [number, number] = [new Date(2026, 2, 9, 6).getTime(), new Date(2026, 2, 21).getTime()];
|
|
3244
|
+
|
|
3245
|
+
const NAME = new Map(WORK.map((item) => [item.id, item.name]));
|
|
3246
|
+
const PERSON = new Map(PEOPLE.map((person) => [person.id, person.name]));
|
|
3247
|
+
|
|
3248
|
+
export default function TeamLeave() {
|
|
3249
|
+
const clashes = findings(WORK, [], BLOCKED).inBlockedTime;
|
|
3250
|
+
return (
|
|
3251
|
+
<Stack gap={3}>
|
|
3252
|
+
<Schedule ariaLabel="Sprint 14 with the team's leave" initialDomain={SPRINT_14} height={500}>
|
|
3253
|
+
{PEOPLE.map((person) => (
|
|
3254
|
+
<Lane key={person.id} id={person.id} label={person.name} />
|
|
3255
|
+
))}
|
|
3256
|
+
<BlockedTimes data={BLOCKED} />
|
|
3257
|
+
<Subtasks data={WORK} tasks={PROJECTS} />
|
|
3258
|
+
</Schedule>
|
|
3259
|
+
<Text size="sm" tone="secondary">
|
|
3260
|
+
{clashes.length === 0
|
|
3261
|
+
? "No work falls into anyone's leave."
|
|
3262
|
+
: clashes.map((clash) => `${NAME.get(clash.subtask)} (${PERSON.get(WORK.find((w) => w.id === clash.subtask)?.lane ?? "")})`).join(" · ")}
|
|
3263
|
+
</Text>
|
|
3264
|
+
</Stack>
|
|
3265
|
+
);
|
|
3266
|
+
}
|
|
3267
|
+
```
|
|
3268
|
+
|
|
3269
|
+
#### When to use something else
|
|
3270
|
+
|
|
3271
|
+
- Hours nobody works, on every lane → Time axis and calendar
|
|
3272
|
+
|
|
3273
|
+
#### API
|
|
3274
|
+
|
|
3275
|
+
##### `BlockedTimesProps`
|
|
3276
|
+
|
|
3277
|
+
| Prop | Type | Default | Description |
|
|
3278
|
+
|---|---|---|---|
|
|
3279
|
+
| `data` (required) | `readonly BlockedTime[]` | — | The blocked time of the lanes - leave, maintenance, unavailability -, as the caller holds it: one list, each interval naming its lane. |
|
|
3280
|
+
|
|
3281
|
+
##### `BlockedTime`
|
|
3282
|
+
|
|
3283
|
+
| Prop | Type | Default | Description |
|
|
3284
|
+
|---|---|---|---|
|
|
3285
|
+
| `id` (required) | `string` | — | The caller's identity of the interval - what a finding names. |
|
|
2592
3286
|
| `lane` (required) | `string` | — | The id of the lane it blocks. |
|
|
2593
3287
|
| `from` (required) | `number` | — | Where it begins. |
|
|
2594
3288
|
| `to` (required) | `number` | — | Where it ends. |
|
|
@@ -2613,12 +3307,136 @@ Pass an instant to `now` and a line crosses the lanes there: a dispatcher at 10:
|
|
|
2613
3307
|
```tsx
|
|
2614
3308
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
2615
3309
|
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
2616
|
-
import { NOW, TOURS, VEHICLES } from "./logistics";
|
|
2617
3310
|
|
|
2618
|
-
/*
|
|
2619
|
-
|
|
3311
|
+
/* Data from the logistics world, written out here so the example runs on its own. */
|
|
3312
|
+
|
|
3313
|
+
/** A small LCG - the same numbers on every computer. */
|
|
3314
|
+
function random(seed: number): () => number {
|
|
3315
|
+
let s = seed >>> 0;
|
|
3316
|
+
return () => {
|
|
3317
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
3318
|
+
return s / 4294967296;
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
2620
3321
|
|
|
2621
3322
|
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
3323
|
+
const MINUTE = 60_000;
|
|
3324
|
+
|
|
3325
|
+
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
3326
|
+
const NOW = at(10, 30);
|
|
3327
|
+
|
|
3328
|
+
interface Vehicle {
|
|
3329
|
+
id: string;
|
|
3330
|
+
plate: string;
|
|
3331
|
+
type: "van" | "e-van" | "truck";
|
|
3332
|
+
/** Payload, in kg. */
|
|
3333
|
+
capacity: number;
|
|
3334
|
+
depot: string;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
const VEHICLES: readonly Vehicle[] = [
|
|
3338
|
+
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
|
|
3339
|
+
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
|
|
3340
|
+
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
|
|
3341
|
+
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
|
|
3342
|
+
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
|
|
3343
|
+
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
|
|
3344
|
+
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
|
|
3345
|
+
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
|
|
3346
|
+
];
|
|
3347
|
+
|
|
3348
|
+
const DRIVERS = [
|
|
3349
|
+
{ id: "d1", name: "Martin Hale", depot: "north" },
|
|
3350
|
+
{ id: "d2", name: "Nadia Petrova", depot: "north" },
|
|
3351
|
+
{ id: "d3", name: "Owen Carter", depot: "north" },
|
|
3352
|
+
{ id: "d4", name: "Lucia Romero", depot: "river" },
|
|
3353
|
+
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
|
|
3354
|
+
{ id: "d6", name: "Hanna Berg", depot: "east" },
|
|
3355
|
+
{ id: "d7", name: "Yusuf Demir", depot: "east" },
|
|
3356
|
+
{ id: "d8", name: "Clara Wendt", depot: "east" },
|
|
3357
|
+
] as const;
|
|
3358
|
+
|
|
3359
|
+
const CUSTOMERS = [
|
|
3360
|
+
"Holloway Garden Supplies",
|
|
3361
|
+
"Marlow & Finch Books",
|
|
3362
|
+
"Oakridge Pharmacy",
|
|
3363
|
+
"Brixley Cycles",
|
|
3364
|
+
"Tamsin's Bakery",
|
|
3365
|
+
"Northfold Office",
|
|
3366
|
+
"Pellham Hardware",
|
|
3367
|
+
"Greywick Studio",
|
|
3368
|
+
"Juniper Lane Florist",
|
|
3369
|
+
"Ashcombe Dental",
|
|
3370
|
+
] as const;
|
|
3371
|
+
|
|
3372
|
+
interface Stop {
|
|
3373
|
+
id: string;
|
|
3374
|
+
shipment: string;
|
|
3375
|
+
customer: string;
|
|
3376
|
+
/** The window the customer was promised, `[from, to]`. */
|
|
3377
|
+
window: readonly [number, number];
|
|
3378
|
+
/** When the tour plans to be there. */
|
|
3379
|
+
arrival: number;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
interface Tour {
|
|
3383
|
+
id: string;
|
|
3384
|
+
vehicle: string;
|
|
3385
|
+
driver: string;
|
|
3386
|
+
depot: string;
|
|
3387
|
+
/** Leaves the depot, loaded; back at the depot. */
|
|
3388
|
+
from: number;
|
|
3389
|
+
to: number;
|
|
3390
|
+
/** Minutes of loading before `from`. */
|
|
3391
|
+
loading: number;
|
|
3392
|
+
km: number;
|
|
3393
|
+
stops: readonly Stop[];
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
|
|
3397
|
+
window; every seventh arrival misses its window - the late ones a
|
|
3398
|
+
dispatcher looks for. */
|
|
3399
|
+
function tour(index: number): Tour {
|
|
3400
|
+
const r = random(700 + index);
|
|
3401
|
+
const vehicle = VEHICLES[index]!;
|
|
3402
|
+
const truck = vehicle.type === "truck";
|
|
3403
|
+
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
|
|
3404
|
+
const count = truck ? 5 : 8 + Math.floor(r() * 4);
|
|
3405
|
+
const stops: Stop[] = [];
|
|
3406
|
+
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
|
|
3407
|
+
for (let i = 0; i < count; i++) {
|
|
3408
|
+
const n = index * 20 + i;
|
|
3409
|
+
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
|
|
3410
|
+
/* A late stop's window closed half an hour or more before the arrival. */
|
|
3411
|
+
const late = i >= 2 && n % 7 === 3;
|
|
3412
|
+
const halfHour = 30 * MINUTE;
|
|
3413
|
+
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
|
|
3414
|
+
stops.push({
|
|
3415
|
+
id: `s-${index + 1}-${i + 1}`,
|
|
3416
|
+
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
|
|
3417
|
+
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
|
|
3418
|
+
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
|
|
3419
|
+
arrival,
|
|
3420
|
+
});
|
|
3421
|
+
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
|
|
3422
|
+
}
|
|
3423
|
+
return {
|
|
3424
|
+
id: `T-${String(index + 1).padStart(2, "0")}`,
|
|
3425
|
+
vehicle: vehicle.id,
|
|
3426
|
+
driver: DRIVERS[index]!.id,
|
|
3427
|
+
depot: vehicle.depot,
|
|
3428
|
+
from,
|
|
3429
|
+
to: arrival + 30 * MINUTE,
|
|
3430
|
+
loading: truck ? 45 : 30,
|
|
3431
|
+
km: Math.round((truck ? 140 : 70) + r() * 40),
|
|
3432
|
+
stops,
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
|
|
3437
|
+
|
|
3438
|
+
/* A fixed instant suits a replay and a picture that must hold still; in a
|
|
3439
|
+
live application `now` alone follows the clock - the next example. */
|
|
2622
3440
|
|
|
2623
3441
|
const COLORS = ["light-dark(#2563eb, #6b9bff)", "light-dark(#0d9488, #3cc7b8)", "light-dark(#c2410c, #f08a52)", "light-dark(#7c3aed, #a98bfa)"];
|
|
2624
3442
|
|
|
@@ -2645,8 +3463,6 @@ export default function NowLine() {
|
|
|
2645
3463
|
}
|
|
2646
3464
|
```
|
|
2647
3465
|
|
|
2648
|
-
It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
2649
|
-
|
|
2650
3466
|
##### Follow the clock
|
|
2651
3467
|
|
|
2652
3468
|
`now` on its own reads the clock and moves on by the minute, so a plan left open on a wall screen stays true.
|
|
@@ -2999,7 +3815,48 @@ import { useState } from "react";
|
|
|
2999
3815
|
import { ContextMenu, MenuItem } from "@umriss-ui/core";
|
|
3000
3816
|
import { Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
|
|
3001
3817
|
import type { ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule";
|
|
3002
|
-
|
|
3818
|
+
|
|
3819
|
+
/* Data from the operations world, written out here so the example runs on its own. */
|
|
3820
|
+
|
|
3821
|
+
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
3822
|
+
|
|
3823
|
+
interface Engineer {
|
|
3824
|
+
id: string;
|
|
3825
|
+
name: string;
|
|
3826
|
+
team: string;
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
const ENGINEERS: readonly Engineer[] = [
|
|
3830
|
+
{ id: "priya", name: "Priya Raman", team: "Payments" },
|
|
3831
|
+
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
|
|
3832
|
+
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
|
|
3833
|
+
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
|
|
3834
|
+
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
|
|
3835
|
+
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
|
|
3836
|
+
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
|
|
3837
|
+
{ id: "felix", name: "Felix Brandt", team: "Insights" },
|
|
3838
|
+
];
|
|
3839
|
+
|
|
3840
|
+
interface OnCall {
|
|
3841
|
+
id: string;
|
|
3842
|
+
engineer: string;
|
|
3843
|
+
rotation: "primary" | "secondary";
|
|
3844
|
+
from: number;
|
|
3845
|
+
to: number;
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
|
|
3849
|
+
secondary of one day is the primary of the next. */
|
|
3850
|
+
const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
|
|
3851
|
+
{ rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
|
|
3852
|
+
{ rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
|
|
3853
|
+
].map(({ rotation, engineer }) => ({
|
|
3854
|
+
id: `${rotation}-${16 + day}`,
|
|
3855
|
+
engineer,
|
|
3856
|
+
rotation,
|
|
3857
|
+
from: at(16 + day, 9),
|
|
3858
|
+
to: at(17 + day, 9),
|
|
3859
|
+
}))).flat();
|
|
3003
3860
|
|
|
3004
3861
|
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
3005
3862
|
|
|
@@ -3067,8 +3924,6 @@ export default function OpenAContextMenu() {
|
|
|
3067
3924
|
}
|
|
3068
3925
|
```
|
|
3069
3926
|
|
|
3070
|
-
It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
3071
|
-
|
|
3072
3927
|
#### When to use something else
|
|
3073
3928
|
|
|
3074
3929
|
- You only need to know which task was picked → Selection
|
|
@@ -3236,21 +4091,97 @@ import { useState } from "react";
|
|
|
3236
4091
|
import { Badge, Card, CardBody, Stack, Text } from "@umriss-ui/core";
|
|
3237
4092
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
3238
4093
|
import type { ScheduleInteraction } from "@umriss-ui/schedule";
|
|
3239
|
-
import { PEOPLE, PROJECTS, WORK } from "./planning";
|
|
3240
|
-
import type { WorkItem } from "./planning";
|
|
3241
4094
|
|
|
3242
|
-
|
|
4095
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
3243
4096
|
|
|
3244
|
-
const
|
|
3245
|
-
const WEB_WORK = WORK.filter((item) => WEB.some((person) => person.id === item.lane));
|
|
4097
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
3246
4098
|
|
|
3247
|
-
|
|
3248
|
-
|
|
4099
|
+
interface Person {
|
|
4100
|
+
id: string;
|
|
4101
|
+
name: string;
|
|
4102
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
4103
|
+
team: "Web" | "Apps";
|
|
4104
|
+
/** Hours a week they can be planned for. */
|
|
4105
|
+
capacity: number;
|
|
4106
|
+
}
|
|
3249
4107
|
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
4108
|
+
const PEOPLE: readonly Person[] = [
|
|
4109
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
4110
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
4111
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
4112
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
4113
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
4114
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
4115
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
4116
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
4117
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
4118
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
4119
|
+
];
|
|
4120
|
+
|
|
4121
|
+
interface Project {
|
|
4122
|
+
id: string;
|
|
4123
|
+
name: string;
|
|
4124
|
+
client: string;
|
|
4125
|
+
color: string;
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
const PROJECTS: readonly Project[] = [
|
|
4129
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
4130
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
4131
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
4132
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
4133
|
+
];
|
|
4134
|
+
|
|
4135
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
4136
|
+
interface WorkItem {
|
|
4137
|
+
id: string;
|
|
4138
|
+
/** The project. */
|
|
4139
|
+
task: string;
|
|
4140
|
+
/** The person. */
|
|
4141
|
+
lane: string;
|
|
4142
|
+
from: number;
|
|
4143
|
+
to: number;
|
|
4144
|
+
name: string;
|
|
4145
|
+
sprint: string;
|
|
4146
|
+
/** Hours, as estimated at planning. */
|
|
4147
|
+
estimate: number;
|
|
4148
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
4152
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
4153
|
+
|
|
4154
|
+
const WORK: readonly WorkItem[] = [
|
|
4155
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
4156
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
4157
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
4158
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
4159
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
4160
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
4161
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
4162
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
4163
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
4164
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
4165
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
4166
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
4167
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
4168
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
4169
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
4170
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
4171
|
+
];
|
|
4172
|
+
|
|
4173
|
+
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
4174
|
+
|
|
4175
|
+
const WEB = PEOPLE.filter((person) => person.team === "Web");
|
|
4176
|
+
const WEB_WORK = WORK.filter((item) => WEB.some((person) => person.id === item.lane));
|
|
4177
|
+
|
|
4178
|
+
export default function DetailPanelInstead() {
|
|
4179
|
+
const [item, setItem] = useState<WorkItem | null>(null);
|
|
4180
|
+
|
|
4181
|
+
const onInteraction = (interaction: ScheduleInteraction) => {
|
|
4182
|
+
if (interaction.type !== "hover") return;
|
|
4183
|
+
const hit = interaction.hit;
|
|
4184
|
+
setItem(hit.kind === "subtask" ? (WEB_WORK.find((one) => one.id === hit.subtask.id) ?? null) : null);
|
|
3254
4185
|
};
|
|
3255
4186
|
|
|
3256
4187
|
return (
|
|
@@ -3294,8 +4225,6 @@ export default function DetailPanelInstead() {
|
|
|
3294
4225
|
}
|
|
3295
4226
|
```
|
|
3296
4227
|
|
|
3297
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
3298
|
-
|
|
3299
4228
|
#### API
|
|
3300
4229
|
|
|
3301
4230
|
##### `ScheduleTooltipTarget`
|
|
@@ -3408,7 +4337,97 @@ import { useState } from "react";
|
|
|
3408
4337
|
import { Stack } from "@umriss-ui/core";
|
|
3409
4338
|
import { Lane, Schedule, Subtasks } from "@umriss-ui/schedule";
|
|
3410
4339
|
import type { Subtask, Task } from "@umriss-ui/schedule";
|
|
3411
|
-
|
|
4340
|
+
|
|
4341
|
+
/* Data from the operations world, written out here so the example runs on its own. */
|
|
4342
|
+
|
|
4343
|
+
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
4344
|
+
|
|
4345
|
+
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
4346
|
+
const NOW = at(17, 10, 30);
|
|
4347
|
+
|
|
4348
|
+
interface Service {
|
|
4349
|
+
id: string;
|
|
4350
|
+
name: string;
|
|
4351
|
+
team: string;
|
|
4352
|
+
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
|
|
4353
|
+
tier: 1 | 2 | 3;
|
|
4354
|
+
/** The latency objective: the 95th percentile stays below this, in ms. */
|
|
4355
|
+
latencySlo: number;
|
|
4356
|
+
/** The availability promised for a month, in per cent. */
|
|
4357
|
+
availabilityTarget: number;
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
const SERVICES: readonly Service[] = [
|
|
4361
|
+
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
|
|
4362
|
+
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
|
|
4363
|
+
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
|
|
4364
|
+
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
|
|
4365
|
+
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
|
|
4366
|
+
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
|
|
4367
|
+
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
|
|
4368
|
+
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
|
|
4369
|
+
];
|
|
4370
|
+
|
|
4371
|
+
interface Engineer {
|
|
4372
|
+
id: string;
|
|
4373
|
+
name: string;
|
|
4374
|
+
team: string;
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
const ENGINEERS: readonly Engineer[] = [
|
|
4378
|
+
{ id: "priya", name: "Priya Raman", team: "Payments" },
|
|
4379
|
+
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
|
|
4380
|
+
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
|
|
4381
|
+
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
|
|
4382
|
+
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
|
|
4383
|
+
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
|
|
4384
|
+
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
|
|
4385
|
+
{ id: "felix", name: "Felix Brandt", team: "Insights" },
|
|
4386
|
+
];
|
|
4387
|
+
|
|
4388
|
+
interface OnCall {
|
|
4389
|
+
id: string;
|
|
4390
|
+
engineer: string;
|
|
4391
|
+
rotation: "primary" | "secondary";
|
|
4392
|
+
from: number;
|
|
4393
|
+
to: number;
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
|
|
4397
|
+
secondary of one day is the primary of the next. */
|
|
4398
|
+
const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
|
|
4399
|
+
{ rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
|
|
4400
|
+
{ rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
|
|
4401
|
+
].map(({ rotation, engineer }) => ({
|
|
4402
|
+
id: `${rotation}-${16 + day}`,
|
|
4403
|
+
engineer,
|
|
4404
|
+
rotation,
|
|
4405
|
+
from: at(16 + day, 9),
|
|
4406
|
+
to: at(17 + day, 9),
|
|
4407
|
+
}))).flat();
|
|
4408
|
+
|
|
4409
|
+
interface Incident {
|
|
4410
|
+
id: string;
|
|
4411
|
+
title: string;
|
|
4412
|
+
service: string;
|
|
4413
|
+
severity: "SEV1" | "SEV2" | "SEV3";
|
|
4414
|
+
opened: number;
|
|
4415
|
+
acknowledged?: number;
|
|
4416
|
+
resolved?: number;
|
|
4417
|
+
/** An engineer's id. */
|
|
4418
|
+
assignee: string;
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4421
|
+
const INCIDENTS: readonly Incident[] = [
|
|
4422
|
+
{ id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" },
|
|
4423
|
+
{ id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
|
|
4424
|
+
{ id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" },
|
|
4425
|
+
{ id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" },
|
|
4426
|
+
{ id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" },
|
|
4427
|
+
{ id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" },
|
|
4428
|
+
{ id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" },
|
|
4429
|
+
{ id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" },
|
|
4430
|
+
];
|
|
3412
4431
|
|
|
3413
4432
|
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
3414
4433
|
|
|
@@ -3463,8 +4482,6 @@ export default function RotaBesideIncidents() {
|
|
|
3463
4482
|
}
|
|
3464
4483
|
```
|
|
3465
4484
|
|
|
3466
|
-
It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
3467
|
-
|
|
3468
4485
|
#### When to use something else
|
|
3469
4486
|
|
|
3470
4487
|
- The lanes belong together in one plan → Lane groups
|
|
@@ -3719,7 +4736,84 @@ import { useRef, useState } from "react";
|
|
|
3719
4736
|
import { Stack, Text } from "@umriss-ui/core";
|
|
3720
4737
|
import { Lane, Schedule, Subtasks, applyIntent, overlaps } from "@umriss-ui/schedule";
|
|
3721
4738
|
import type { Intent, Subtask } from "@umriss-ui/schedule";
|
|
3722
|
-
|
|
4739
|
+
|
|
4740
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
4741
|
+
|
|
4742
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
4743
|
+
|
|
4744
|
+
interface Person {
|
|
4745
|
+
id: string;
|
|
4746
|
+
name: string;
|
|
4747
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
4748
|
+
team: "Web" | "Apps";
|
|
4749
|
+
/** Hours a week they can be planned for. */
|
|
4750
|
+
capacity: number;
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
const PEOPLE: readonly Person[] = [
|
|
4754
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
4755
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
4756
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
4757
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
4758
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
4759
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
4760
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
4761
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
4762
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
4763
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
4764
|
+
];
|
|
4765
|
+
|
|
4766
|
+
interface Project {
|
|
4767
|
+
id: string;
|
|
4768
|
+
name: string;
|
|
4769
|
+
client: string;
|
|
4770
|
+
color: string;
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
const PROJECTS: readonly Project[] = [
|
|
4774
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
4775
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
4776
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
4777
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
4778
|
+
];
|
|
4779
|
+
|
|
4780
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
4781
|
+
interface WorkItem {
|
|
4782
|
+
id: string;
|
|
4783
|
+
/** The project. */
|
|
4784
|
+
task: string;
|
|
4785
|
+
/** The person. */
|
|
4786
|
+
lane: string;
|
|
4787
|
+
from: number;
|
|
4788
|
+
to: number;
|
|
4789
|
+
name: string;
|
|
4790
|
+
sprint: string;
|
|
4791
|
+
/** Hours, as estimated at planning. */
|
|
4792
|
+
estimate: number;
|
|
4793
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
4794
|
+
}
|
|
4795
|
+
|
|
4796
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
4797
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
4798
|
+
|
|
4799
|
+
const WORK: readonly WorkItem[] = [
|
|
4800
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
4801
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
4802
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
4803
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
4804
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
4805
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
4806
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
4807
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
4808
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
4809
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
4810
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
4811
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
4812
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
4813
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
4814
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
4815
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
4816
|
+
];
|
|
3723
4817
|
|
|
3724
4818
|
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
3725
4819
|
|
|
@@ -3769,8 +4863,6 @@ export default function RefuseADoubleBooking() {
|
|
|
3769
4863
|
}
|
|
3770
4864
|
```
|
|
3771
4865
|
|
|
3772
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
3773
|
-
|
|
3774
4866
|
#### When to use something else
|
|
3775
4867
|
|
|
3776
4868
|
- The work is not on the plan yet → Placing from outside
|
|
@@ -4354,32 +5446,127 @@ import { useState } from "react";
|
|
|
4354
5446
|
import { Stack, Text } from "@umriss-ui/core";
|
|
4355
5447
|
import { BlockedTimes, Lane, Schedule, Subtasks, applyIntent } from "@umriss-ui/schedule";
|
|
4356
5448
|
import type { BlockedTime, Intent, Subtask } from "@umriss-ui/schedule";
|
|
4357
|
-
import { LEAVE, PEOPLE, PROJECTS, WORK } from "./planning";
|
|
4358
|
-
|
|
4359
|
-
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
4360
5449
|
|
|
4361
|
-
|
|
4362
|
-
const START: readonly Subtask[] = WORK.filter((item) => APPS.some((person) => person.id === item.lane));
|
|
4363
|
-
const AWAY: readonly BlockedTime[] = LEAVE.filter((leave) => APPS.some((person) => person.id === leave.person)).map((leave) => ({
|
|
4364
|
-
id: leave.id,
|
|
4365
|
-
lane: leave.person,
|
|
4366
|
-
from: leave.from,
|
|
4367
|
-
to: leave.to,
|
|
4368
|
-
label: leave.reason,
|
|
4369
|
-
}));
|
|
5450
|
+
/* Data from the planning world, written out here so the example runs on its own. */
|
|
4370
5451
|
|
|
4371
|
-
|
|
4372
|
-
const roleOf = (lane: string) => PEOPLE.find((person) => person.id === lane)?.role;
|
|
4373
|
-
const START_ROLE = new Map(START.map((item) => [item.id, roleOf(item.lane)]));
|
|
4374
|
-
const mayGo = (item: Subtask, lane: string) => START_ROLE.get(item.id) === roleOf(lane);
|
|
5452
|
+
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
4375
5453
|
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
5454
|
+
interface Person {
|
|
5455
|
+
id: string;
|
|
5456
|
+
name: string;
|
|
5457
|
+
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
5458
|
+
team: "Web" | "Apps";
|
|
5459
|
+
/** Hours a week they can be planned for. */
|
|
5460
|
+
capacity: number;
|
|
5461
|
+
}
|
|
4379
5462
|
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
5463
|
+
const PEOPLE: readonly Person[] = [
|
|
5464
|
+
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
5465
|
+
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
5466
|
+
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
5467
|
+
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
5468
|
+
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
5469
|
+
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
5470
|
+
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
5471
|
+
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
5472
|
+
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
5473
|
+
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
5474
|
+
];
|
|
5475
|
+
|
|
5476
|
+
interface Project {
|
|
5477
|
+
id: string;
|
|
5478
|
+
name: string;
|
|
5479
|
+
client: string;
|
|
5480
|
+
color: string;
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5483
|
+
const PROJECTS: readonly Project[] = [
|
|
5484
|
+
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
5485
|
+
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
5486
|
+
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
5487
|
+
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
5488
|
+
];
|
|
5489
|
+
|
|
5490
|
+
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
5491
|
+
interface WorkItem {
|
|
5492
|
+
id: string;
|
|
5493
|
+
/** The project. */
|
|
5494
|
+
task: string;
|
|
5495
|
+
/** The person. */
|
|
5496
|
+
lane: string;
|
|
5497
|
+
from: number;
|
|
5498
|
+
to: number;
|
|
5499
|
+
name: string;
|
|
5500
|
+
sprint: string;
|
|
5501
|
+
/** Hours, as estimated at planning. */
|
|
5502
|
+
estimate: number;
|
|
5503
|
+
status: "to do" | "in progress" | "in review" | "done";
|
|
5504
|
+
}
|
|
5505
|
+
|
|
5506
|
+
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
5507
|
+
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
5508
|
+
|
|
5509
|
+
const WORK: readonly WorkItem[] = [
|
|
5510
|
+
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
5511
|
+
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
5512
|
+
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
5513
|
+
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
5514
|
+
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
5515
|
+
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
5516
|
+
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
5517
|
+
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
5518
|
+
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
5519
|
+
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
5520
|
+
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
5521
|
+
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
5522
|
+
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
5523
|
+
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
5524
|
+
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
5525
|
+
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
5526
|
+
];
|
|
5527
|
+
|
|
5528
|
+
interface Leave {
|
|
5529
|
+
id: string;
|
|
5530
|
+
person: string;
|
|
5531
|
+
from: number;
|
|
5532
|
+
to: number;
|
|
5533
|
+
reason: "Holiday" | "Sick" | "Training";
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5536
|
+
/** When people are away: blocked time on their lane. */
|
|
5537
|
+
const LEAVE: readonly Leave[] = [
|
|
5538
|
+
{ id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
|
|
5539
|
+
{ id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
|
|
5540
|
+
{ id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
|
|
5541
|
+
{ id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
|
|
5542
|
+
{ id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
|
|
5543
|
+
{ id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
|
|
5544
|
+
];
|
|
5545
|
+
|
|
5546
|
+
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
|
|
5547
|
+
|
|
5548
|
+
const APPS = PEOPLE.filter((person) => person.team === "Apps" && person.role !== "Product manager");
|
|
5549
|
+
const START: readonly Subtask[] = WORK.filter((item) => APPS.some((person) => person.id === item.lane));
|
|
5550
|
+
const AWAY: readonly BlockedTime[] = LEAVE.filter((leave) => APPS.some((person) => person.id === leave.person)).map((leave) => ({
|
|
5551
|
+
id: leave.id,
|
|
5552
|
+
lane: leave.person,
|
|
5553
|
+
from: leave.from,
|
|
5554
|
+
to: leave.to,
|
|
5555
|
+
label: leave.reason,
|
|
5556
|
+
}));
|
|
5557
|
+
|
|
5558
|
+
/* Work stays with people of the role it was planned for. */
|
|
5559
|
+
const roleOf = (lane: string) => PEOPLE.find((person) => person.id === lane)?.role;
|
|
5560
|
+
const START_ROLE = new Map(START.map((item) => [item.id, roleOf(item.lane)]));
|
|
5561
|
+
const mayGo = (item: Subtask, lane: string) => START_ROLE.get(item.id) === roleOf(lane);
|
|
5562
|
+
|
|
5563
|
+
export default function AroundLeave() {
|
|
5564
|
+
const [work, setWork] = useState<readonly Subtask[]>(START);
|
|
5565
|
+
const [last, setLast] = useState("Drag Freya's store screenshots into her holiday");
|
|
5566
|
+
|
|
5567
|
+
const onIntent = (intent: Intent) => {
|
|
5568
|
+
if (intent.kind === "place") return;
|
|
5569
|
+
setWork((current) => current.map((item) => applyIntent(item, intent)));
|
|
4383
5570
|
setLast(`${intent.subtask}: ${intent.kind}`);
|
|
4384
5571
|
};
|
|
4385
5572
|
|
|
@@ -4407,8 +5594,6 @@ export default function AroundLeave() {
|
|
|
4407
5594
|
}
|
|
4408
5595
|
```
|
|
4409
5596
|
|
|
4410
|
-
It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
|
|
4411
|
-
|
|
4412
5597
|
#### When to use something else
|
|
4413
5598
|
|
|
4414
5599
|
- The rule depends on the result, such as no double booking → Move and lane
|
|
@@ -4967,1363 +6152,3 @@ interface ResolvedAppearance {
|
|
|
4967
6152
|
readonly open: boolean;
|
|
4968
6153
|
}
|
|
4969
6154
|
```
|
|
4970
|
-
|
|
4971
|
-
## Files the examples show
|
|
4972
|
-
|
|
4973
|
-
The demos' shared data, which some examples import from beside themselves. Copied with the example, it runs.
|
|
4974
|
-
|
|
4975
|
-
### `logistics.ts`
|
|
4976
|
-
|
|
4977
|
-
```ts
|
|
4978
|
-
/**
|
|
4979
|
-
* The logistics world: Ferrow Parcel, a regional parcel carrier with three
|
|
4980
|
-
* depots.
|
|
4981
|
-
*
|
|
4982
|
-
* The standing cast:
|
|
4983
|
-
* - `DEPOTS`, `VEHICLES` (plate, type, payload, depot) and `DRIVERS`;
|
|
4984
|
-
* - `TOURS` - today's eight delivery tours, each with its vehicle, driver,
|
|
4985
|
-
* start, end, distance and stops; a stop has a delivery window and the
|
|
4986
|
-
* planned arrival, and a few arrive outside their window;
|
|
4987
|
-
* - `SHIPMENTS` - one per stop, with customer, weight, status and window;
|
|
4988
|
-
* - `VEHICLE_STATES` and `vehicleDay(vehicle)` - what a vehicle did through
|
|
4989
|
-
* the day, quarter hour by quarter hour, for a state band;
|
|
4990
|
-
* - `COST_RATES` - what a kilometre, an hour and a vehicle day cost, the
|
|
4991
|
-
* inputs of a cost per tour.
|
|
4992
|
-
*
|
|
4993
|
-
* "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
|
|
4994
|
-
* functions, no imports: copy the file beside an example and it runs.
|
|
4995
|
-
*/
|
|
4996
|
-
|
|
4997
|
-
/** A small LCG - the same numbers on every computer. */
|
|
4998
|
-
function random(seed: number): () => number {
|
|
4999
|
-
let s = seed >>> 0;
|
|
5000
|
-
return () => {
|
|
5001
|
-
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
5002
|
-
return s / 4294967296;
|
|
5003
|
-
};
|
|
5004
|
-
}
|
|
5005
|
-
|
|
5006
|
-
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
5007
|
-
const MINUTE = 60_000;
|
|
5008
|
-
|
|
5009
|
-
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
5010
|
-
export const NOW = at(10, 30);
|
|
5011
|
-
|
|
5012
|
-
export const DEPOTS = [
|
|
5013
|
-
{ id: "north", name: "North depot" },
|
|
5014
|
-
{ id: "river", name: "Riverside depot" },
|
|
5015
|
-
{ id: "east", name: "East Gate depot" },
|
|
5016
|
-
] as const;
|
|
5017
|
-
|
|
5018
|
-
export interface Vehicle {
|
|
5019
|
-
id: string;
|
|
5020
|
-
plate: string;
|
|
5021
|
-
type: "van" | "e-van" | "truck";
|
|
5022
|
-
/** Payload, in kg. */
|
|
5023
|
-
capacity: number;
|
|
5024
|
-
depot: string;
|
|
5025
|
-
}
|
|
5026
|
-
|
|
5027
|
-
export const VEHICLES: readonly Vehicle[] = [
|
|
5028
|
-
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
|
|
5029
|
-
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
|
|
5030
|
-
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
|
|
5031
|
-
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
|
|
5032
|
-
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
|
|
5033
|
-
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
|
|
5034
|
-
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
|
|
5035
|
-
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
|
|
5036
|
-
];
|
|
5037
|
-
|
|
5038
|
-
export const DRIVERS = [
|
|
5039
|
-
{ id: "d1", name: "Martin Hale", depot: "north" },
|
|
5040
|
-
{ id: "d2", name: "Nadia Petrova", depot: "north" },
|
|
5041
|
-
{ id: "d3", name: "Owen Carter", depot: "north" },
|
|
5042
|
-
{ id: "d4", name: "Lucia Romero", depot: "river" },
|
|
5043
|
-
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
|
|
5044
|
-
{ id: "d6", name: "Hanna Berg", depot: "east" },
|
|
5045
|
-
{ id: "d7", name: "Yusuf Demir", depot: "east" },
|
|
5046
|
-
{ id: "d8", name: "Clara Wendt", depot: "east" },
|
|
5047
|
-
] as const;
|
|
5048
|
-
|
|
5049
|
-
const CUSTOMERS = [
|
|
5050
|
-
"Holloway Garden Supplies",
|
|
5051
|
-
"Marlow & Finch Books",
|
|
5052
|
-
"Oakridge Pharmacy",
|
|
5053
|
-
"Brixley Cycles",
|
|
5054
|
-
"Tamsin's Bakery",
|
|
5055
|
-
"Northfold Office",
|
|
5056
|
-
"Pellham Hardware",
|
|
5057
|
-
"Greywick Studio",
|
|
5058
|
-
"Juniper Lane Florist",
|
|
5059
|
-
"Ashcombe Dental",
|
|
5060
|
-
] as const;
|
|
5061
|
-
|
|
5062
|
-
export interface Stop {
|
|
5063
|
-
id: string;
|
|
5064
|
-
shipment: string;
|
|
5065
|
-
customer: string;
|
|
5066
|
-
/** The window the customer was promised, `[from, to]`. */
|
|
5067
|
-
window: readonly [number, number];
|
|
5068
|
-
/** When the tour plans to be there. */
|
|
5069
|
-
arrival: number;
|
|
5070
|
-
}
|
|
5071
|
-
|
|
5072
|
-
export interface Tour {
|
|
5073
|
-
id: string;
|
|
5074
|
-
vehicle: string;
|
|
5075
|
-
driver: string;
|
|
5076
|
-
depot: string;
|
|
5077
|
-
/** Leaves the depot, loaded; back at the depot. */
|
|
5078
|
-
from: number;
|
|
5079
|
-
to: number;
|
|
5080
|
-
/** Minutes of loading before `from`. */
|
|
5081
|
-
loading: number;
|
|
5082
|
-
km: number;
|
|
5083
|
-
stops: readonly Stop[];
|
|
5084
|
-
}
|
|
5085
|
-
|
|
5086
|
-
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
|
|
5087
|
-
window; every seventh arrival misses its window - the late ones a
|
|
5088
|
-
dispatcher looks for. */
|
|
5089
|
-
function tour(index: number): Tour {
|
|
5090
|
-
const r = random(700 + index);
|
|
5091
|
-
const vehicle = VEHICLES[index]!;
|
|
5092
|
-
const truck = vehicle.type === "truck";
|
|
5093
|
-
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
|
|
5094
|
-
const count = truck ? 5 : 8 + Math.floor(r() * 4);
|
|
5095
|
-
const stops: Stop[] = [];
|
|
5096
|
-
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
|
|
5097
|
-
for (let i = 0; i < count; i++) {
|
|
5098
|
-
const n = index * 20 + i;
|
|
5099
|
-
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
|
|
5100
|
-
/* A late stop's window closed half an hour or more before the arrival. */
|
|
5101
|
-
const late = i >= 2 && n % 7 === 3;
|
|
5102
|
-
const halfHour = 30 * MINUTE;
|
|
5103
|
-
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
|
|
5104
|
-
stops.push({
|
|
5105
|
-
id: `s-${index + 1}-${i + 1}`,
|
|
5106
|
-
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
|
|
5107
|
-
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
|
|
5108
|
-
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
|
|
5109
|
-
arrival,
|
|
5110
|
-
});
|
|
5111
|
-
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
|
|
5112
|
-
}
|
|
5113
|
-
return {
|
|
5114
|
-
id: `T-${String(index + 1).padStart(2, "0")}`,
|
|
5115
|
-
vehicle: vehicle.id,
|
|
5116
|
-
driver: DRIVERS[index]!.id,
|
|
5117
|
-
depot: vehicle.depot,
|
|
5118
|
-
from,
|
|
5119
|
-
to: arrival + 30 * MINUTE,
|
|
5120
|
-
loading: truck ? 45 : 30,
|
|
5121
|
-
km: Math.round((truck ? 140 : 70) + r() * 40),
|
|
5122
|
-
stops,
|
|
5123
|
-
};
|
|
5124
|
-
}
|
|
5125
|
-
|
|
5126
|
-
export const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
|
|
5127
|
-
|
|
5128
|
-
export interface Shipment {
|
|
5129
|
-
id: string;
|
|
5130
|
-
customer: string;
|
|
5131
|
-
/** In kg. */
|
|
5132
|
-
weight: number;
|
|
5133
|
-
status: "delivered" | "out for delivery" | "failed attempt";
|
|
5134
|
-
window: readonly [number, number];
|
|
5135
|
-
tour: string;
|
|
5136
|
-
}
|
|
5137
|
-
|
|
5138
|
-
/** One shipment per stop. Before now, delivered - or a failed attempt, where
|
|
5139
|
-
nobody was in; after now, out for delivery. */
|
|
5140
|
-
export const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
|
|
5141
|
-
one.stops.map((stop) => {
|
|
5142
|
-
const n = Number(stop.shipment.slice(3));
|
|
5143
|
-
return {
|
|
5144
|
-
id: stop.shipment,
|
|
5145
|
-
customer: stop.customer,
|
|
5146
|
-
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
|
|
5147
|
-
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
|
|
5148
|
-
window: stop.window,
|
|
5149
|
-
tour: one.id,
|
|
5150
|
-
} satisfies Shipment;
|
|
5151
|
-
}),
|
|
5152
|
-
);
|
|
5153
|
-
|
|
5154
|
-
/** The closed set of vehicle states. The order is the code. */
|
|
5155
|
-
export const VEHICLE_STATES = [
|
|
5156
|
-
{ label: "Driving", color: "#2f6fb4" },
|
|
5157
|
-
{ label: "Loading", color: "#c08a2e" },
|
|
5158
|
-
{ label: "Idle", color: "#8a8f98" },
|
|
5159
|
-
{ label: "Break", color: "#6b8e5a" },
|
|
5160
|
-
] as const;
|
|
5161
|
-
|
|
5162
|
-
export interface StatePoint {
|
|
5163
|
-
t: number;
|
|
5164
|
-
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
|
|
5165
|
-
state: number | null;
|
|
5166
|
-
}
|
|
5167
|
-
|
|
5168
|
-
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
|
|
5169
|
-
loading before its tour, driving between stops, idle at them now and then,
|
|
5170
|
-
and a break around noon. */
|
|
5171
|
-
export function vehicleDay(vehicleId: string): StatePoint[] {
|
|
5172
|
-
const one = TOURS.find((t) => t.vehicle === vehicleId);
|
|
5173
|
-
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
|
|
5174
|
-
const r = random(900 + TOURS.indexOf(one));
|
|
5175
|
-
const points: StatePoint[] = [];
|
|
5176
|
-
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
|
|
5177
|
-
let state: number | null = null;
|
|
5178
|
-
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
|
|
5179
|
-
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
|
|
5180
|
-
points.push({ t, state });
|
|
5181
|
-
}
|
|
5182
|
-
return points;
|
|
5183
|
-
}
|
|
5184
|
-
|
|
5185
|
-
/** What a tour costs, per unit - the inputs of a cost per tour. */
|
|
5186
|
-
export const COST_RATES = {
|
|
5187
|
-
/** Diesel, per litre; electricity, per kWh. */
|
|
5188
|
-
dieselPerLitre: 1.68,
|
|
5189
|
-
electricityPerKwh: 0.31,
|
|
5190
|
-
/** Consumption per 100 km. */
|
|
5191
|
-
per100Km: { van: 9.5, truck: 24, "e-van": 22 },
|
|
5192
|
-
/** A driver's hour, all in. */
|
|
5193
|
-
driverPerHour: 34.5,
|
|
5194
|
-
/** Lease, insurance and upkeep, per day. */
|
|
5195
|
-
vehiclePerDay: { van: 58, truck: 145, "e-van": 72 },
|
|
5196
|
-
} as const;
|
|
5197
|
-
|
|
5198
|
-
/* ---------------------------------------------------------------------------
|
|
5199
|
-
For @umriss-ui/charts: an e-van's battery, the North depot's week and
|
|
5200
|
-
fortnight, the delays by cause and the parcels per depot and hour.
|
|
5201
|
-
--------------------------------------------------------------------------- */
|
|
5202
|
-
|
|
5203
|
-
const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
|
|
5204
|
-
const HOUR = 60 * MINUTE;
|
|
5205
|
-
|
|
5206
|
-
export interface BatteryPoint {
|
|
5207
|
-
t: number;
|
|
5208
|
-
/** State of charge, in per cent; `null` before the tour and after it. */
|
|
5209
|
-
charge: number | null;
|
|
5210
|
-
}
|
|
5211
|
-
|
|
5212
|
-
/** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
|
|
5213
|
-
full when the tour leaves, draining while it drives, a little while it
|
|
5214
|
-
idles, and topped up at the depot's charger over the noon break. */
|
|
5215
|
-
export function batteryDay(vehicleId: string): BatteryPoint[] {
|
|
5216
|
-
let charge = 100;
|
|
5217
|
-
return vehicleDay(vehicleId).map(({ t, state }) => {
|
|
5218
|
-
if (state === null) return { t, charge: null };
|
|
5219
|
-
const now = charge;
|
|
5220
|
-
charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
|
|
5221
|
-
return { t, charge: Math.round(now * 10) / 10 };
|
|
5222
|
-
});
|
|
5223
|
-
}
|
|
5224
|
-
|
|
5225
|
-
/** The North depot's sorting hours: Monday to Friday, 06:00 to 22:00. */
|
|
5226
|
-
export const SORTING_HOURS = Array.from({ length: 5 }, (_, day) => ({ from: on(3, 9 + day, 6), to: on(3, 9 + day, 22) }));
|
|
5227
|
-
|
|
5228
|
-
export interface SortedPoint {
|
|
5229
|
-
t: number;
|
|
5230
|
-
/** Parcels sorted per hour. */
|
|
5231
|
-
parcels: number;
|
|
5232
|
-
}
|
|
5233
|
-
|
|
5234
|
-
/** Last week's sorting at the North depot, quarter hour by quarter hour -
|
|
5235
|
-
only in its sorting hours. */
|
|
5236
|
-
export const SORTED: readonly SortedPoint[] = (() => {
|
|
5237
|
-
const r = random(1963);
|
|
5238
|
-
const points: SortedPoint[] = [];
|
|
5239
|
-
let parcels = 420;
|
|
5240
|
-
for (const { from } of SORTING_HOURS) {
|
|
5241
|
-
for (let quarter = 0; quarter < 4 * 16; quarter++) {
|
|
5242
|
-
parcels += (r() - 0.5) * 26;
|
|
5243
|
-
points.push({ t: from + quarter * 15 * MINUTE, parcels: Math.round(parcels) });
|
|
5244
|
-
}
|
|
5245
|
-
}
|
|
5246
|
-
return points;
|
|
5247
|
-
})();
|
|
5248
|
-
|
|
5249
|
-
export interface DepotDay {
|
|
5250
|
-
/** Day of March. */
|
|
5251
|
-
day: number;
|
|
5252
|
-
/** Pallets that arrived, and that left on the tours. */
|
|
5253
|
-
arrived: number;
|
|
5254
|
-
dispatched: number;
|
|
5255
|
-
/** Pallets on hand at the end of the day. */
|
|
5256
|
-
onHand: number;
|
|
5257
|
-
/** The range the stock is meant to stay in; `null` while it was reset
|
|
5258
|
-
after the stocktake. */
|
|
5259
|
-
targetLow: number | null;
|
|
5260
|
-
targetHigh: number | null;
|
|
5261
|
-
/** A pallet count by hand - it never quite agrees with the books. */
|
|
5262
|
-
counted: number;
|
|
5263
|
-
}
|
|
5264
|
-
|
|
5265
|
-
/** The North depot's last fourteen days, 2 to 15 March. */
|
|
5266
|
-
export const DEPOT_DAYS: readonly DepotDay[] = (() => {
|
|
5267
|
-
const r = random(2026);
|
|
5268
|
-
let onHand = 62;
|
|
5269
|
-
return Array.from({ length: 14 }, (_, i) => {
|
|
5270
|
-
const arrived = 30 + r() * 25;
|
|
5271
|
-
const dispatched = 22 + r() * 20;
|
|
5272
|
-
onHand += (arrived - dispatched) * 0.35;
|
|
5273
|
-
const reset = i >= 6 && i <= 8;
|
|
5274
|
-
return {
|
|
5275
|
-
day: i + 2,
|
|
5276
|
-
arrived: Math.round(arrived),
|
|
5277
|
-
dispatched: Math.round(dispatched),
|
|
5278
|
-
onHand: Math.round(onHand),
|
|
5279
|
-
targetLow: reset ? null : Math.round(onHand - 9 - r() * 3),
|
|
5280
|
-
targetHigh: reset ? null : Math.round(onHand + 9 + r() * 3),
|
|
5281
|
-
counted: Math.round(onHand + (r() - 0.5) * 26),
|
|
5282
|
-
};
|
|
5283
|
-
});
|
|
5284
|
-
})();
|
|
5285
|
-
|
|
5286
|
-
export interface DayDelays {
|
|
5287
|
-
/** The position on the x axis: the working day's index. */
|
|
5288
|
-
day: number;
|
|
5289
|
-
name: string;
|
|
5290
|
-
/** Minutes late across all tours, by cause; `null` where it was not logged. */
|
|
5291
|
-
traffic: number;
|
|
5292
|
-
loading: number | null;
|
|
5293
|
-
access: number;
|
|
5294
|
-
}
|
|
5295
|
-
|
|
5296
|
-
/** Two working weeks, 2 to 13 March. On the first Wednesday the loading log
|
|
5297
|
-
was not kept - a gap, not a zero - and on the second Thursday a closed
|
|
5298
|
-
bridge takes the day. */
|
|
5299
|
-
export const DELAYS: readonly DayDelays[] = (() => {
|
|
5300
|
-
const r = random(1204);
|
|
5301
|
-
const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
|
|
5302
|
-
return Array.from({ length: 10 }, (_, day) => ({
|
|
5303
|
-
day,
|
|
5304
|
-
name: `${names[day % 5]} ${2 + day + 2 * Math.floor(day / 5)}`,
|
|
5305
|
-
traffic: Math.round((day === 8 ? 150 : 40) + r() * 30),
|
|
5306
|
-
loading: day === 2 ? null : Math.round(10 + r() * 45),
|
|
5307
|
-
access: Math.round(5 + r() * 25),
|
|
5308
|
-
}));
|
|
5309
|
-
})();
|
|
5310
|
-
|
|
5311
|
-
export interface DepotHour {
|
|
5312
|
-
t: number;
|
|
5313
|
-
/** Parcels loaded per hour at each depot. */
|
|
5314
|
-
north: number;
|
|
5315
|
-
river: number;
|
|
5316
|
-
east: number;
|
|
5317
|
-
}
|
|
5318
|
-
|
|
5319
|
-
/** Monday 06:00 to Tuesday 06:00, hour by hour. East Gate closes at night and
|
|
5320
|
-
loads nothing - 0, not nothing. */
|
|
5321
|
-
export const PARCELS_PER_HOUR: readonly DepotHour[] = (() => {
|
|
5322
|
-
const r = random(906);
|
|
5323
|
-
return Array.from({ length: 25 }, (_, i) => {
|
|
5324
|
-
const hour = (6 + i) % 24;
|
|
5325
|
-
const night = hour >= 22 || hour < 6;
|
|
5326
|
-
return {
|
|
5327
|
-
t: on(3, 16, 6) + i * HOUR,
|
|
5328
|
-
north: Math.round(120 + r() * 30),
|
|
5329
|
-
river: Math.round((night ? 60 : 90) + r() * 25),
|
|
5330
|
-
east: night ? 0 : Math.round(70 + r() * 30),
|
|
5331
|
-
};
|
|
5332
|
-
});
|
|
5333
|
-
})();
|
|
5334
|
-
```
|
|
5335
|
-
|
|
5336
|
-
### `operations.ts`
|
|
5337
|
-
|
|
5338
|
-
```ts
|
|
5339
|
-
/**
|
|
5340
|
-
* The operations world: Quillmere, a platform on which small businesses take
|
|
5341
|
-
* online bookings, run as a set of services by six teams.
|
|
5342
|
-
*
|
|
5343
|
-
* The standing cast:
|
|
5344
|
-
* - `SERVICES` - eight services with their team, tier, latency objective and
|
|
5345
|
-
* availability target;
|
|
5346
|
-
* - `ENGINEERS` - the people on call, and `ONCALL`, who is primary and
|
|
5347
|
-
* secondary this week;
|
|
5348
|
-
* - `metrics(service)` - latency and error rate every five minutes of today up
|
|
5349
|
-
* to now; Checkout has a bad half hour from 09:40;
|
|
5350
|
-
* - `INCIDENTS` - the last week's incidents, one of them still open;
|
|
5351
|
-
* - `ALERT_TYPES` and `ALERTS` - what the alert list shows at 10:30;
|
|
5352
|
-
* - `DOWNTIME_MINUTES` - each service's downtime this month, for its
|
|
5353
|
-
* availability.
|
|
5354
|
-
*
|
|
5355
|
-
* "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
|
|
5356
|
-
* functions, no imports: copy the file beside an example and it runs.
|
|
5357
|
-
*/
|
|
5358
|
-
|
|
5359
|
-
/** A small LCG - the same numbers on every computer. */
|
|
5360
|
-
function random(seed: number): () => number {
|
|
5361
|
-
let s = seed >>> 0;
|
|
5362
|
-
return () => {
|
|
5363
|
-
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
5364
|
-
return s / 4294967296;
|
|
5365
|
-
};
|
|
5366
|
-
}
|
|
5367
|
-
|
|
5368
|
-
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
5369
|
-
const MINUTE = 60_000;
|
|
5370
|
-
|
|
5371
|
-
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
5372
|
-
export const NOW = at(17, 10, 30);
|
|
5373
|
-
|
|
5374
|
-
export interface Service {
|
|
5375
|
-
id: string;
|
|
5376
|
-
name: string;
|
|
5377
|
-
team: string;
|
|
5378
|
-
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
|
|
5379
|
-
tier: 1 | 2 | 3;
|
|
5380
|
-
/** The latency objective: the 95th percentile stays below this, in ms. */
|
|
5381
|
-
latencySlo: number;
|
|
5382
|
-
/** The availability promised for a month, in per cent. */
|
|
5383
|
-
availabilityTarget: number;
|
|
5384
|
-
}
|
|
5385
|
-
|
|
5386
|
-
export const SERVICES: readonly Service[] = [
|
|
5387
|
-
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
|
|
5388
|
-
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
|
|
5389
|
-
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
|
|
5390
|
-
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
|
|
5391
|
-
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
|
|
5392
|
-
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
|
|
5393
|
-
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
|
|
5394
|
-
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
|
|
5395
|
-
];
|
|
5396
|
-
|
|
5397
|
-
export interface Engineer {
|
|
5398
|
-
id: string;
|
|
5399
|
-
name: string;
|
|
5400
|
-
team: string;
|
|
5401
|
-
}
|
|
5402
|
-
|
|
5403
|
-
export const ENGINEERS: readonly Engineer[] = [
|
|
5404
|
-
{ id: "priya", name: "Priya Raman", team: "Payments" },
|
|
5405
|
-
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
|
|
5406
|
-
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
|
|
5407
|
-
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
|
|
5408
|
-
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
|
|
5409
|
-
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
|
|
5410
|
-
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
|
|
5411
|
-
{ id: "felix", name: "Felix Brandt", team: "Insights" },
|
|
5412
|
-
];
|
|
5413
|
-
|
|
5414
|
-
export interface OnCall {
|
|
5415
|
-
id: string;
|
|
5416
|
-
engineer: string;
|
|
5417
|
-
rotation: "primary" | "secondary";
|
|
5418
|
-
from: number;
|
|
5419
|
-
to: number;
|
|
5420
|
-
}
|
|
5421
|
-
|
|
5422
|
-
/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
|
|
5423
|
-
secondary of one day is the primary of the next. */
|
|
5424
|
-
export const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
|
|
5425
|
-
{ rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
|
|
5426
|
-
{ rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
|
|
5427
|
-
].map(({ rotation, engineer }) => ({
|
|
5428
|
-
id: `${rotation}-${16 + day}`,
|
|
5429
|
-
engineer,
|
|
5430
|
-
rotation,
|
|
5431
|
-
from: at(16 + day, 9),
|
|
5432
|
-
to: at(17 + day, 9),
|
|
5433
|
-
}))).flat();
|
|
5434
|
-
|
|
5435
|
-
export interface MetricPoint {
|
|
5436
|
-
t: number;
|
|
5437
|
-
/** Median and 95th percentile latency, in ms. */
|
|
5438
|
-
p50: number;
|
|
5439
|
-
p95: number;
|
|
5440
|
-
/** Failed requests, in per cent. */
|
|
5441
|
-
errorRate: number;
|
|
5442
|
-
/** Requests per minute. */
|
|
5443
|
-
requests: number;
|
|
5444
|
-
}
|
|
5445
|
-
|
|
5446
|
-
/** Requests per minute at the busiest hour, by tier. */
|
|
5447
|
-
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
|
|
5448
|
-
|
|
5449
|
-
/** A service's latency and error rate every five minutes of today, from
|
|
5450
|
-
midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
|
|
5451
|
-
climbs well past its objective from 09:40 to 10:10, and its errors with it. */
|
|
5452
|
-
export function metrics(serviceId: string): MetricPoint[] {
|
|
5453
|
-
const index = SERVICES.findIndex((one) => one.id === serviceId);
|
|
5454
|
-
const service = SERVICES[index];
|
|
5455
|
-
if (service === undefined) throw new Error(`No service "${serviceId}".`);
|
|
5456
|
-
const r = random(1000 + index * 37);
|
|
5457
|
-
const points: MetricPoint[] = [];
|
|
5458
|
-
let drift = 0;
|
|
5459
|
-
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
|
|
5460
|
-
const hour = (t - at(17, 0)) / (60 * MINUTE);
|
|
5461
|
-
const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
|
|
5462
|
-
drift = 0.8 * drift + (r() - 0.5) * 0.08;
|
|
5463
|
-
const base = service.latencySlo * (0.45 + 0.2 * load + drift);
|
|
5464
|
-
const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
|
|
5465
|
-
const p95 = Math.round(base * surge * (1 + r() * 0.08));
|
|
5466
|
-
points.push({
|
|
5467
|
-
t,
|
|
5468
|
-
p50: Math.round(p95 * (0.42 + r() * 0.06)),
|
|
5469
|
-
p95,
|
|
5470
|
-
errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
|
|
5471
|
-
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
|
|
5472
|
-
});
|
|
5473
|
-
}
|
|
5474
|
-
return points;
|
|
5475
|
-
}
|
|
5476
|
-
|
|
5477
|
-
export interface Incident {
|
|
5478
|
-
id: string;
|
|
5479
|
-
title: string;
|
|
5480
|
-
service: string;
|
|
5481
|
-
severity: "SEV1" | "SEV2" | "SEV3";
|
|
5482
|
-
opened: number;
|
|
5483
|
-
acknowledged?: number;
|
|
5484
|
-
resolved?: number;
|
|
5485
|
-
/** An engineer's id. */
|
|
5486
|
-
assignee: string;
|
|
5487
|
-
}
|
|
5488
|
-
|
|
5489
|
-
export const INCIDENTS: readonly Incident[] = [
|
|
5490
|
-
{ id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" },
|
|
5491
|
-
{ id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
|
|
5492
|
-
{ id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" },
|
|
5493
|
-
{ id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" },
|
|
5494
|
-
{ id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" },
|
|
5495
|
-
{ id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" },
|
|
5496
|
-
{ id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" },
|
|
5497
|
-
{ id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" },
|
|
5498
|
-
];
|
|
5499
|
-
|
|
5500
|
-
/** A kind of alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
|
|
5501
|
-
export interface AlertType {
|
|
5502
|
-
id: string;
|
|
5503
|
-
label: string;
|
|
5504
|
-
priority: "high" | "medium" | "low";
|
|
5505
|
-
}
|
|
5506
|
-
|
|
5507
|
-
export const ALERT_TYPES: readonly AlertType[] = [
|
|
5508
|
-
{ id: "checkout-latency", label: "Checkout · p95 latency above 300 ms", priority: "high" },
|
|
5509
|
-
{ id: "checkout-errors", label: "Checkout · error rate above 2\u00a0%", priority: "high" },
|
|
5510
|
-
{ id: "webhooks-queue", label: "Webhooks · queue older than 5 min", priority: "medium" },
|
|
5511
|
-
{ id: "search-latency", label: "Search · p95 latency above 250 ms", priority: "medium" },
|
|
5512
|
-
{ id: "images-disk", label: "Image service · disk 85 % full", priority: "low" },
|
|
5513
|
-
{ id: "reports-job", label: "Reporting · nightly job late", priority: "low" },
|
|
5514
|
-
];
|
|
5515
|
-
|
|
5516
|
-
/** One alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
|
|
5517
|
-
export type Alert = {
|
|
5518
|
-
id: string;
|
|
5519
|
-
type: string;
|
|
5520
|
-
lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
|
|
5521
|
-
raised: number;
|
|
5522
|
-
resolved?: number;
|
|
5523
|
-
acknowledgedAt?: number;
|
|
5524
|
-
} & (
|
|
5525
|
-
| { availability?: "in-service" | "suppressed" | "disabled"; snooze?: undefined }
|
|
5526
|
-
| { availability: "snoozed"; snooze: { until: number; by: string } }
|
|
5527
|
-
);
|
|
5528
|
-
|
|
5529
|
-
/** The alerts as they stand at 10:30. */
|
|
5530
|
-
export const ALERTS: readonly Alert[] = [
|
|
5531
|
-
{ id: "a-1", type: "checkout-latency", lifecycle: "active-acknowledged", raised: at(17, 9, 41), acknowledgedAt: at(17, 9, 46) },
|
|
5532
|
-
{ id: "a-2", type: "checkout-errors", lifecycle: "resolved-unacknowledged", raised: at(17, 9, 43), resolved: at(17, 10, 11) },
|
|
5533
|
-
{ id: "a-3", type: "webhooks-queue", lifecycle: "active-acknowledged", raised: at(17, 7, 12), acknowledgedAt: at(17, 8, 2) },
|
|
5534
|
-
{ id: "a-4", type: "search-latency", lifecycle: "active-unacknowledged", raised: at(17, 10, 24) },
|
|
5535
|
-
{ id: "a-5", type: "search-latency", lifecycle: "resolved-acknowledged", raised: at(17, 8, 5), resolved: at(17, 8, 9), acknowledgedAt: at(17, 8, 6) },
|
|
5536
|
-
{ id: "a-6", type: "images-disk", lifecycle: "active-unacknowledged", raised: at(17, 6, 0), availability: "snoozed", snooze: { until: at(17, 14), by: "Tomasz Nowak" } },
|
|
5537
|
-
{ id: "a-7", type: "reports-job", lifecycle: "active-unacknowledged", raised: at(17, 4, 30), availability: "disabled" },
|
|
5538
|
-
];
|
|
5539
|
-
|
|
5540
|
-
/** The month so far: 1 March 00:00 to now, in minutes. */
|
|
5541
|
-
export const MONTH_MINUTES = (NOW - at(1, 0)) / MINUTE;
|
|
5542
|
-
|
|
5543
|
-
/** Minutes each service was down this month. With `MONTH_MINUTES` and the
|
|
5544
|
-
service's target, what an availability is worked out from. */
|
|
5545
|
-
export const DOWNTIME_MINUTES: Readonly<Record<string, number>> = {
|
|
5546
|
-
checkout: 34,
|
|
5547
|
-
billing: 18,
|
|
5548
|
-
"sign-in": 41,
|
|
5549
|
-
search: 85,
|
|
5550
|
-
images: 120,
|
|
5551
|
-
notifications: 12,
|
|
5552
|
-
webhooks: 210,
|
|
5553
|
-
reports: 95,
|
|
5554
|
-
};
|
|
5555
|
-
|
|
5556
|
-
/* ---------------------------------------------------------------------------
|
|
5557
|
-
For @umriss-ui/charts: last week at any resolution, a load test, single
|
|
5558
|
-
traced requests, the autoscaler's log, the expected range and yesterday's
|
|
5559
|
-
success rates.
|
|
5560
|
-
--------------------------------------------------------------------------- */
|
|
5561
|
-
|
|
5562
|
-
const HOUR = 60 * MINUTE;
|
|
5563
|
-
const DAY = 24 * HOUR;
|
|
5564
|
-
|
|
5565
|
-
/** Monday, 9 March 2026, 00:00 - the start of last week. */
|
|
5566
|
-
export const LAST_WEEK = at(9, 0);
|
|
5567
|
-
|
|
5568
|
-
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
|
|
5569
|
-
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
|
|
5570
|
-
|
|
5571
|
-
/** A service's last week, Monday to Monday, one point every `step`
|
|
5572
|
-
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
|
|
5573
|
-
95th percentile more than doubles and its errors climb. The hour is counted,
|
|
5574
|
-
not asked of a Date - a week of seconds is 604,800 points. */
|
|
5575
|
-
export function week(serviceId: string, step: number): MetricPoint[] {
|
|
5576
|
-
const index = SERVICES.findIndex((one) => one.id === serviceId);
|
|
5577
|
-
const service = SERVICES[index];
|
|
5578
|
-
if (service === undefined) throw new Error(`No service "${serviceId}".`);
|
|
5579
|
-
const r = random(2000 + index * 37);
|
|
5580
|
-
const n = Math.floor((7 * DAY) / step);
|
|
5581
|
-
const points = new Array<MetricPoint>(n);
|
|
5582
|
-
/* The drift wanders at the same pace whatever the step. */
|
|
5583
|
-
const keep = Math.pow(0.8, step / (5 * MINUTE));
|
|
5584
|
-
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
|
|
5585
|
-
let drift = 0;
|
|
5586
|
-
for (let i = 0; i < n; i++) {
|
|
5587
|
-
const offset = i * step;
|
|
5588
|
-
const day = Math.floor(offset / DAY);
|
|
5589
|
-
const hour = (offset % DAY) / HOUR;
|
|
5590
|
-
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
|
|
5591
|
-
drift = keep * drift + (r() - 0.5) * kick;
|
|
5592
|
-
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
|
|
5593
|
-
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
|
|
5594
|
-
points[i] = {
|
|
5595
|
-
t: LAST_WEEK + offset,
|
|
5596
|
-
p50: Math.round(p95 * (0.42 + r() * 0.06)),
|
|
5597
|
-
p95,
|
|
5598
|
-
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
|
|
5599
|
-
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
|
|
5600
|
-
};
|
|
5601
|
-
}
|
|
5602
|
-
return points;
|
|
5603
|
-
}
|
|
5604
|
-
|
|
5605
|
-
/** When this morning's load test of Search began. */
|
|
5606
|
-
export interface LoadTestPoint {
|
|
5607
|
-
/** Minutes since the test began. */
|
|
5608
|
-
minute: number;
|
|
5609
|
-
/** CPU load of the busiest node, in per cent. */
|
|
5610
|
-
cpu: number;
|
|
5611
|
-
requestsPerHour: number;
|
|
5612
|
-
/** 95th percentile latency, in ms. */
|
|
5613
|
-
p95: number;
|
|
5614
|
-
}
|
|
5615
|
-
|
|
5616
|
-
/** An hour of load test, minute by minute: three magnitudes far apart. */
|
|
5617
|
-
export const LOAD_TEST: readonly LoadTestPoint[] = (() => {
|
|
5618
|
-
const r = random(99);
|
|
5619
|
-
let cpu = 21;
|
|
5620
|
-
let requestsPerHour = 128_000;
|
|
5621
|
-
let p95 = 640;
|
|
5622
|
-
return Array.from({ length: 60 }, (_, minute) => {
|
|
5623
|
-
cpu += (r() - 0.5) * 0.9;
|
|
5624
|
-
requestsPerHour += (r() - 0.5) * 9000;
|
|
5625
|
-
p95 += (r() - 0.5) * 40;
|
|
5626
|
-
return { minute, cpu, requestsPerHour, p95 };
|
|
5627
|
-
});
|
|
5628
|
-
})();
|
|
5629
|
-
|
|
5630
|
-
export interface Trace {
|
|
5631
|
-
t: number;
|
|
5632
|
-
/** How long the request took, end to end, in ms. */
|
|
5633
|
-
ms: number;
|
|
5634
|
-
}
|
|
5635
|
-
|
|
5636
|
-
/** Single Checkout requests traced end to end this morning, one every few
|
|
5637
|
-
minutes from 06:00 - individual measurements, nothing in between. Slow
|
|
5638
|
-
during the bad half hour, and now and then on their own. */
|
|
5639
|
-
export const TRACES: readonly Trace[] = (() => {
|
|
5640
|
-
const r = random(4040);
|
|
5641
|
-
const traces: Trace[] = [];
|
|
5642
|
-
for (let t = at(17, 6, 2); t <= NOW; t += Math.round(1 + r() * 4) * MINUTE) {
|
|
5643
|
-
const bad = t >= at(17, 9, 40) && t < at(17, 10, 10);
|
|
5644
|
-
const ms = 150 + r() * 90 + (bad ? 110 + r() * 120 : r() < 0.05 ? 150 : 0);
|
|
5645
|
-
traces.push({ t, ms: Math.round(ms) });
|
|
5646
|
-
}
|
|
5647
|
-
return traces;
|
|
5648
|
-
})();
|
|
5649
|
-
|
|
5650
|
-
export interface ReplicaChange {
|
|
5651
|
-
t: number;
|
|
5652
|
-
/** Checkout's instances from here on; `null` while the autoscaler was paused. */
|
|
5653
|
-
replicas: number | null;
|
|
5654
|
-
}
|
|
5655
|
-
|
|
5656
|
-
/** Checkout's instances today, logged only when the autoscaler changes them.
|
|
5657
|
-
It was paused for a database upgrade from 03:00 to 04:00; the last entry,
|
|
5658
|
-
at now, closes the hold. */
|
|
5659
|
-
export const REPLICAS: readonly ReplicaChange[] = [
|
|
5660
|
-
{ t: at(17, 0), replicas: 4 },
|
|
5661
|
-
{ t: at(17, 3), replicas: null },
|
|
5662
|
-
{ t: at(17, 4), replicas: 3 },
|
|
5663
|
-
{ t: at(17, 6, 30), replicas: 5 },
|
|
5664
|
-
{ t: at(17, 8), replicas: 8 },
|
|
5665
|
-
{ t: at(17, 9, 45), replicas: 12 },
|
|
5666
|
-
{ t: at(17, 10, 15), replicas: 9 },
|
|
5667
|
-
{ t: NOW, replicas: 9 },
|
|
5668
|
-
];
|
|
5669
|
-
|
|
5670
|
-
export interface ExpectedPoint {
|
|
5671
|
-
t: number;
|
|
5672
|
-
/** The range the requests per minute are expected in; `null` where the
|
|
5673
|
-
job that computes it did not run. */
|
|
5674
|
-
low: number | null;
|
|
5675
|
-
high: number | null;
|
|
5676
|
-
}
|
|
5677
|
-
|
|
5678
|
-
/** The range a service's requests per minute are expected in today, every
|
|
5679
|
-
five minutes - the day's usual curve, give or take 15 %. The job that
|
|
5680
|
-
computes it skipped 03:00 to 04:00. */
|
|
5681
|
-
export function expected(serviceId: string): ExpectedPoint[] {
|
|
5682
|
-
const service = SERVICES.find((one) => one.id === serviceId);
|
|
5683
|
-
if (service === undefined) throw new Error(`No service "${serviceId}".`);
|
|
5684
|
-
const points: ExpectedPoint[] = [];
|
|
5685
|
-
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
|
|
5686
|
-
const hour = (t - at(17, 0)) / HOUR;
|
|
5687
|
-
const usual = VOLUME[service.tier] * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
|
|
5688
|
-
const skipped = hour >= 3 && hour < 4;
|
|
5689
|
-
points.push({ t, low: skipped ? null : Math.round(usual * 0.85), high: skipped ? null : Math.round(usual * 1.15) });
|
|
5690
|
-
}
|
|
5691
|
-
return points;
|
|
5692
|
-
}
|
|
5693
|
-
|
|
5694
|
-
export interface SuccessCell {
|
|
5695
|
-
hour: number;
|
|
5696
|
-
/** The row: the service's place in `SERVICES`. */
|
|
5697
|
-
service: number;
|
|
5698
|
-
/** Requests that succeeded, in per cent; `null` where there were none. */
|
|
5699
|
-
success: number | null;
|
|
5700
|
-
}
|
|
5701
|
-
|
|
5702
|
-
/** Yesterday, per service and hour: the share of requests that succeeded.
|
|
5703
|
-
Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
|
|
5704
|
-
and Reporting takes no requests before 06:00 - no requests, no share. */
|
|
5705
|
-
export const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
|
|
5706
|
-
const r = random(23);
|
|
5707
|
-
const cells: SuccessCell[] = [];
|
|
5708
|
-
SERVICES.forEach((one, service) => {
|
|
5709
|
-
for (let hour = 0; hour < 24; hour++) {
|
|
5710
|
-
let success: number | null = 99.2 + r() * 0.8;
|
|
5711
|
-
if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
|
|
5712
|
-
if (hour === 3) success = 97.4 + r() * 1;
|
|
5713
|
-
if (one.id === "reports" && hour < 6) success = null;
|
|
5714
|
-
cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
|
|
5715
|
-
}
|
|
5716
|
-
});
|
|
5717
|
-
return cells;
|
|
5718
|
-
})();
|
|
5719
|
-
```
|
|
5720
|
-
|
|
5721
|
-
### `planning.ts`
|
|
5722
|
-
|
|
5723
|
-
```ts
|
|
5724
|
-
/**
|
|
5725
|
-
* The planning world: Tidewell, a small studio that builds web and mobile
|
|
5726
|
-
* apps for its clients, in two teams.
|
|
5727
|
-
*
|
|
5728
|
-
* The standing cast:
|
|
5729
|
-
* - `PEOPLE` - ten people with their role, team and weekly capacity in hours;
|
|
5730
|
-
* - `PROJECTS` - four client projects, each with a colour;
|
|
5731
|
-
* - `SPRINTS` - two-week sprints; sprint 14 runs now, 9 to 20 March;
|
|
5732
|
-
* - `WORK` - the current sprint's work items with an estimate and a status,
|
|
5733
|
-
* placed on a person over time; structurally a `Subtask` of
|
|
5734
|
-
* `@umriss-ui/schedule` (`task` is the project, `lane` the person);
|
|
5735
|
-
* - `LEAVE` - holidays, sick days and training, to become blocked time;
|
|
5736
|
-
* - `BURNDOWN` - sprint 14's remaining hours at the end of each working day,
|
|
5737
|
-
* up to yesterday.
|
|
5738
|
-
*
|
|
5739
|
-
* "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
|
|
5740
|
-
* functions, no imports: copy the file beside an example and it runs.
|
|
5741
|
-
*/
|
|
5742
|
-
|
|
5743
|
-
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
|
|
5744
|
-
|
|
5745
|
-
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
|
|
5746
|
-
export const NOW = at(17, 10, 30);
|
|
5747
|
-
|
|
5748
|
-
export interface Person {
|
|
5749
|
-
id: string;
|
|
5750
|
-
name: string;
|
|
5751
|
-
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
|
|
5752
|
-
team: "Web" | "Apps";
|
|
5753
|
-
/** Hours a week they can be planned for. */
|
|
5754
|
-
capacity: number;
|
|
5755
|
-
}
|
|
5756
|
-
|
|
5757
|
-
export const PEOPLE: readonly Person[] = [
|
|
5758
|
-
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
|
|
5759
|
-
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
|
|
5760
|
-
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
|
|
5761
|
-
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
|
|
5762
|
-
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
|
|
5763
|
-
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
|
|
5764
|
-
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
|
|
5765
|
-
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
|
|
5766
|
-
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
|
|
5767
|
-
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
|
|
5768
|
-
];
|
|
5769
|
-
|
|
5770
|
-
/** A project, shaped as `@umriss-ui/schedule`'s `Task`: an id, a name and a
|
|
5771
|
-
colour in both schemes. */
|
|
5772
|
-
export interface Project {
|
|
5773
|
-
id: string;
|
|
5774
|
-
name: string;
|
|
5775
|
-
client: string;
|
|
5776
|
-
color: string;
|
|
5777
|
-
}
|
|
5778
|
-
|
|
5779
|
-
export const PROJECTS: readonly Project[] = [
|
|
5780
|
-
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
|
|
5781
|
-
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
5782
|
-
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
5783
|
-
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
|
|
5784
|
-
];
|
|
5785
|
-
|
|
5786
|
-
export interface Sprint {
|
|
5787
|
-
id: string;
|
|
5788
|
-
name: string;
|
|
5789
|
-
from: number;
|
|
5790
|
-
to: number;
|
|
5791
|
-
goal: string;
|
|
5792
|
-
}
|
|
5793
|
-
|
|
5794
|
-
/** Two weeks each, Monday 09:00 to the second Friday 17:00. */
|
|
5795
|
-
export const SPRINTS: readonly Sprint[] = [
|
|
5796
|
-
{ name: "Sprint 12", day: 9 - 28, goal: "Shop checkout on the new design" },
|
|
5797
|
-
{ name: "Sprint 13", day: 9 - 14, goal: "Booking app in the stores' beta" },
|
|
5798
|
-
{ name: "Sprint 14", day: 9, goal: "Member portal sign-in and profile" },
|
|
5799
|
-
{ name: "Sprint 15", day: 23, goal: "Shop search and filters" },
|
|
5800
|
-
{ name: "Sprint 16", day: 37, goal: "Booking reminders" },
|
|
5801
|
-
].map(({ name, day, goal }) => ({ id: name.toLowerCase().replace(" ", "-"), name, from: at(day), to: at(day + 11, 17), goal }));
|
|
5802
|
-
|
|
5803
|
-
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
|
|
5804
|
-
export interface WorkItem {
|
|
5805
|
-
id: string;
|
|
5806
|
-
/** The project. */
|
|
5807
|
-
task: string;
|
|
5808
|
-
/** The person. */
|
|
5809
|
-
lane: string;
|
|
5810
|
-
from: number;
|
|
5811
|
-
to: number;
|
|
5812
|
-
name: string;
|
|
5813
|
-
sprint: string;
|
|
5814
|
-
/** Hours, as estimated at planning. */
|
|
5815
|
-
estimate: number;
|
|
5816
|
-
status: "to do" | "in progress" | "in review" | "done";
|
|
5817
|
-
}
|
|
5818
|
-
|
|
5819
|
-
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
|
|
5820
|
-
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
|
|
5821
|
-
|
|
5822
|
-
export const WORK: readonly WorkItem[] = [
|
|
5823
|
-
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
|
|
5824
|
-
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
|
|
5825
|
-
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
|
|
5826
|
-
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
|
|
5827
|
-
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
|
|
5828
|
-
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
|
|
5829
|
-
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
|
|
5830
|
-
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
|
|
5831
|
-
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
|
|
5832
|
-
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
|
|
5833
|
-
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
|
|
5834
|
-
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
|
|
5835
|
-
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
|
|
5836
|
-
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
|
|
5837
|
-
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
|
|
5838
|
-
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
|
|
5839
|
-
];
|
|
5840
|
-
|
|
5841
|
-
export interface Leave {
|
|
5842
|
-
id: string;
|
|
5843
|
-
person: string;
|
|
5844
|
-
from: number;
|
|
5845
|
-
to: number;
|
|
5846
|
-
reason: "Holiday" | "Sick" | "Training";
|
|
5847
|
-
}
|
|
5848
|
-
|
|
5849
|
-
/** When people are away: blocked time on their lane. */
|
|
5850
|
-
export const LEAVE: readonly Leave[] = [
|
|
5851
|
-
{ id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
|
|
5852
|
-
{ id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
|
|
5853
|
-
{ id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
|
|
5854
|
-
{ id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
|
|
5855
|
-
{ id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
|
|
5856
|
-
{ id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
|
|
5857
|
-
];
|
|
5858
|
-
|
|
5859
|
-
export interface BurndownPoint {
|
|
5860
|
-
/** The working day, at 17:00. */
|
|
5861
|
-
t: number;
|
|
5862
|
-
/** Where the line would be, burning evenly. */
|
|
5863
|
-
ideal: number;
|
|
5864
|
-
/** Hours left, `null` for the days still to come. */
|
|
5865
|
-
remaining: number | null;
|
|
5866
|
-
}
|
|
5867
|
-
|
|
5868
|
-
/** Sprint 14's ten working days. The team fell behind in the first week, when
|
|
5869
|
-
two were away, and is catching up. */
|
|
5870
|
-
export const BURNDOWN: readonly BurndownPoint[] = (() => {
|
|
5871
|
-
const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
|
|
5872
|
-
const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
|
|
5873
|
-
const burnt = [0, 22, 38, 60, 71, 98];
|
|
5874
|
-
return days.map((day, i) => ({
|
|
5875
|
-
t: at(day, 17),
|
|
5876
|
-
ideal: Math.round(total * (1 - i / (days.length - 1))),
|
|
5877
|
-
remaining: burnt[i] === undefined ? null : total - burnt[i]!,
|
|
5878
|
-
}));
|
|
5879
|
-
})();
|
|
5880
|
-
```
|
|
5881
|
-
|
|
5882
|
-
### `plant.ts`
|
|
5883
|
-
|
|
5884
|
-
```ts
|
|
5885
|
-
/**
|
|
5886
|
-
* The plant world: Brenholt Tile Works, a tile maker with one kiln line, and the
|
|
5887
|
-
* machine shop next door.
|
|
5888
|
-
*
|
|
5889
|
-
* The standing cast:
|
|
5890
|
-
* - the kiln line - press, dryer D1 and kiln K1 - over one early shift, minute
|
|
5891
|
-
* by minute: readings, samples, batches and the alarms they raise (`plant`);
|
|
5892
|
-
* - the machine shop's plan for Tuesday, 17 March 2026: seven stations, six
|
|
5893
|
-
* orders, their steps and the dependencies between them (`STATIONS`,
|
|
5894
|
-
* `ORDERS`, `STEPS`, `MOVES`).
|
|
5895
|
-
*
|
|
5896
|
-
* Plain data and small pure functions, no imports: copy the file beside an
|
|
5897
|
-
* example and it runs. Seeded, so the same seed gives the same numbers on
|
|
5898
|
-
* every machine; nothing here reads a clock.
|
|
5899
|
-
*/
|
|
5900
|
-
|
|
5901
|
-
/* The few shapes the umriss packages expect, written out so this file needs
|
|
5902
|
-
no import. The data is structurally what the packages take. */
|
|
5903
|
-
|
|
5904
|
-
/** A limit set as `@umriss-ui/core` reads it. */
|
|
5905
|
-
interface LimitSet {
|
|
5906
|
-
target?: number;
|
|
5907
|
-
limits?: readonly { value: number; side: "upper" | "lower"; severity: "warning" | "alarm" }[];
|
|
5908
|
-
}
|
|
5909
|
-
|
|
5910
|
-
/** An alarm's dead band, as `@umriss-ui/table` reads it. */
|
|
5911
|
-
interface ReturnBand {
|
|
5912
|
-
direction: "upper" | "lower";
|
|
5913
|
-
limit: number;
|
|
5914
|
-
returnTo: number;
|
|
5915
|
-
}
|
|
5916
|
-
|
|
5917
|
-
/** A kind of alarm, as `@umriss-ui/table` reads it. */
|
|
5918
|
-
interface AlarmType {
|
|
5919
|
-
id: string;
|
|
5920
|
-
label: string;
|
|
5921
|
-
priority: "high" | "medium" | "low";
|
|
5922
|
-
returnBand?: ReturnBand;
|
|
5923
|
-
}
|
|
5924
|
-
|
|
5925
|
-
/** One alarm, as `@umriss-ui/table` reads it. */
|
|
5926
|
-
interface Alarm {
|
|
5927
|
-
id: string;
|
|
5928
|
-
type: string;
|
|
5929
|
-
lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
|
|
5930
|
-
raised: number;
|
|
5931
|
-
resolved?: number;
|
|
5932
|
-
acknowledgedAt?: number;
|
|
5933
|
-
availability?: "in-service" | "suppressed" | "disabled";
|
|
5934
|
-
}
|
|
5935
|
-
|
|
5936
|
-
/** A task, a subtask and a dependency, as `@umriss-ui/schedule` reads them. */
|
|
5937
|
-
interface Task {
|
|
5938
|
-
readonly id: string;
|
|
5939
|
-
readonly name?: string;
|
|
5940
|
-
readonly color: string;
|
|
5941
|
-
}
|
|
5942
|
-
interface Subtask {
|
|
5943
|
-
readonly id: string;
|
|
5944
|
-
readonly task: string;
|
|
5945
|
-
readonly lane: string;
|
|
5946
|
-
readonly from: number;
|
|
5947
|
-
readonly to: number;
|
|
5948
|
-
readonly leadIn?: number;
|
|
5949
|
-
readonly leadOut?: number;
|
|
5950
|
-
}
|
|
5951
|
-
interface Dependency {
|
|
5952
|
-
readonly id: string;
|
|
5953
|
-
readonly from: string;
|
|
5954
|
-
readonly to: string;
|
|
5955
|
-
readonly lag: number;
|
|
5956
|
-
readonly leaves?: "main" | "leadOut";
|
|
5957
|
-
readonly arrives?: "main" | "leadIn";
|
|
5958
|
-
}
|
|
5959
|
-
|
|
5960
|
-
/** A small LCG - reproducible across runs and platforms. */
|
|
5961
|
-
export function random(seed: number): () => number {
|
|
5962
|
-
let s = seed >>> 0;
|
|
5963
|
-
return () => {
|
|
5964
|
-
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
5965
|
-
return s / 4294967296;
|
|
5966
|
-
};
|
|
5967
|
-
}
|
|
5968
|
-
|
|
5969
|
-
/* ---------------------------------------------------------------------------
|
|
5970
|
-
The kiln line over an early shift.
|
|
5971
|
-
|
|
5972
|
-
Every part of a control room reads from this one shift - the trend, the
|
|
5973
|
-
tiles, the alarm list, the control chart, the plan and the OEE. The limit
|
|
5974
|
-
the kiln crosses in the trend is the alarm in the list, the verdict on the
|
|
5975
|
-
tile and the scrap in the OEE, because all four are read off the same
|
|
5976
|
-
minute of the same readings. The line counts in minutes from the start of
|
|
5977
|
-
the shift, and whoever draws it adds the instant the shift began.
|
|
5978
|
-
|
|
5979
|
-
The line generates alarms, which is exactly what the library does not do
|
|
5980
|
-
(ADR-0009): it decides that a reading above the limit is an alarm, and when
|
|
5981
|
-
it has come back far enough to clear. The table's model takes it from
|
|
5982
|
-
there.
|
|
5983
|
-
--------------------------------------------------------------------------- */
|
|
5984
|
-
|
|
5985
|
-
/** The early shift, 06:00 to 14:00. */
|
|
5986
|
-
export const SHIFT_MINUTES = 480;
|
|
5987
|
-
|
|
5988
|
-
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
|
|
5989
|
-
Between the two warnings lies the tolerance - a tile fired outside it is
|
|
5990
|
-
scrap; `returnTo` is the alarm's dead band. */
|
|
5991
|
-
export const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
|
|
5992
|
-
|
|
5993
|
-
/** The same numbers as the limit set the tile and the verdict read. */
|
|
5994
|
-
export const KILN_LIMITS: LimitSet = {
|
|
5995
|
-
target: KILN.target,
|
|
5996
|
-
limits: [
|
|
5997
|
-
{ value: KILN.tolerance[1], side: "upper", severity: "warning" },
|
|
5998
|
-
{ value: KILN.alarm, side: "upper", severity: "alarm" },
|
|
5999
|
-
{ value: KILN.tolerance[0], side: "lower", severity: "warning" },
|
|
6000
|
-
],
|
|
6001
|
-
};
|
|
6002
|
-
|
|
6003
|
-
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
|
|
6004
|
-
the ideal cycle time of the OEE's performance. */
|
|
6005
|
-
export const IDEAL_CYCLE_MINUTES = 0.25;
|
|
6006
|
-
|
|
6007
|
-
/** The kiln alarm's condition and its dead band, as the table's model reads them. */
|
|
6008
|
-
const KILN_RETURN: ReturnBand = { direction: "upper", limit: KILN.alarm, returnTo: KILN.returnTo };
|
|
6009
|
-
|
|
6010
|
-
/** What the four measuring points of the line can report. */
|
|
6011
|
-
export const ALARM_TYPES: readonly AlarmType[] = [
|
|
6012
|
-
{
|
|
6013
|
-
id: "kiln-high",
|
|
6014
|
-
label: "Kiln K1 · zone 3 above alarm limit",
|
|
6015
|
-
priority: "high",
|
|
6016
|
-
/* The dead band: back below `returnTo`, not merely below the limit, or a
|
|
6017
|
-
reading riding on the limit would raise an alarm a minute. */
|
|
6018
|
-
returnBand: KILN_RETURN,
|
|
6019
|
-
},
|
|
6020
|
-
{ id: "exit-silent", label: "Kiln K1 · exit pyrometer sends nothing", priority: "medium" },
|
|
6021
|
-
{ id: "belt-empty", label: "Kiln K1 · belt empty", priority: "medium" },
|
|
6022
|
-
{ id: "dryer-fan", label: "Dryer D1 · fan vibration high", priority: "low" },
|
|
6023
|
-
];
|
|
6024
|
-
|
|
6025
|
-
/** One minute of the line. */
|
|
6026
|
-
export interface Reading {
|
|
6027
|
-
/** Minutes since the start of the shift. */
|
|
6028
|
-
minute: number;
|
|
6029
|
-
/** Zone 3 of the kiln, in °C. */
|
|
6030
|
-
kiln: number;
|
|
6031
|
-
/** The exit pyrometer, in °C - `null` while it sends nothing. */
|
|
6032
|
-
exit: number | null;
|
|
6033
|
-
/** Whether the belt runs. */
|
|
6034
|
-
running: boolean;
|
|
6035
|
-
/** Tiles out of the kiln in this minute. */
|
|
6036
|
-
fired: number;
|
|
6037
|
-
/** Of them, the ones fired inside the tolerance. */
|
|
6038
|
-
good: number;
|
|
6039
|
-
}
|
|
6040
|
-
|
|
6041
|
-
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
|
|
6042
|
-
export interface Sample {
|
|
6043
|
-
minute: number;
|
|
6044
|
-
/** The tile's length after firing, in mm (nominal 600). */
|
|
6045
|
-
length: number;
|
|
6046
|
-
}
|
|
6047
|
-
|
|
6048
|
-
/** A batch of tiles through the three stations. */
|
|
6049
|
-
export interface Batch {
|
|
6050
|
-
id: string;
|
|
6051
|
-
name: string;
|
|
6052
|
-
/** How many tiles the batch is planned for. */
|
|
6053
|
-
tiles: number;
|
|
6054
|
-
/** Minutes since the start of the shift, per station. */
|
|
6055
|
-
press: readonly [number, number];
|
|
6056
|
-
dryer: readonly [number, number];
|
|
6057
|
-
kiln: readonly [number, number];
|
|
6058
|
-
}
|
|
6059
|
-
|
|
6060
|
-
export interface Plant {
|
|
6061
|
-
readings: readonly Reading[];
|
|
6062
|
-
samples: readonly Sample[];
|
|
6063
|
-
batches: readonly Batch[];
|
|
6064
|
-
}
|
|
6065
|
-
|
|
6066
|
-
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
|
|
6067
|
-
seeded, so that a running control room shows its reading turn stale, then
|
|
6068
|
-
lost, and come back. */
|
|
6069
|
-
const SILENT_FROM = 288;
|
|
6070
|
-
const SILENT_FOR = 50;
|
|
6071
|
-
|
|
6072
|
-
/* The dryer's vibration sensor is under maintenance all shift: its alarm is
|
|
6073
|
-
active and disabled. */
|
|
6074
|
-
const DRYER_FAN_RAISED = 20;
|
|
6075
|
-
|
|
6076
|
-
/** The shift, from a seed. */
|
|
6077
|
-
export function plant(seed: number): Plant {
|
|
6078
|
-
const r = random(seed);
|
|
6079
|
-
|
|
6080
|
-
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
|
|
6081
|
-
into the shift, for half an hour, peaking near 1242 °C. The operator stops
|
|
6082
|
-
the feed twenty-two minutes in, for eighteen minutes. The noise around it
|
|
6083
|
-
is too small to reach a warning on its own, so this is the only crossing -
|
|
6084
|
-
which is what the tests hold. */
|
|
6085
|
-
const surgeFrom = 150 + Math.floor(r() * 60);
|
|
6086
|
-
const surgeFor = 30;
|
|
6087
|
-
const stopFrom = surgeFrom + 22;
|
|
6088
|
-
const stopFor = 18;
|
|
6089
|
-
|
|
6090
|
-
/* The plan: batches through the kiln one after another, each pressed an
|
|
6091
|
-
hour and dried half an hour before - the first ones during the night
|
|
6092
|
-
shift. Tiles come out of the kiln only while a batch is in it, so the
|
|
6093
|
-
OEE's count and the batches' counts are the same tiles. The plan is a
|
|
6094
|
-
plan: a stop of the belt does not move it. */
|
|
6095
|
-
const batches: Batch[] = [];
|
|
6096
|
-
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
|
|
6097
|
-
const length = 70 + Math.floor(r() * 25);
|
|
6098
|
-
batches.push({
|
|
6099
|
-
id: `b-${4121 + i}`,
|
|
6100
|
-
name: `B-${4121 + i}`,
|
|
6101
|
-
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
|
|
6102
|
-
press: [at - 60, at - 60 + length],
|
|
6103
|
-
dryer: [at - 30, at - 30 + length],
|
|
6104
|
-
kiln: [at, at + length],
|
|
6105
|
-
});
|
|
6106
|
-
at += length + 5;
|
|
6107
|
-
}
|
|
6108
|
-
|
|
6109
|
-
const readings: Reading[] = [];
|
|
6110
|
-
const samples: Sample[] = [];
|
|
6111
|
-
let drift = 0;
|
|
6112
|
-
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
|
|
6113
|
-
drift = 0.9 * drift + (r() - 0.5) * 3;
|
|
6114
|
-
const into = minute - surgeFrom;
|
|
6115
|
-
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
|
|
6116
|
-
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
|
|
6117
|
-
|
|
6118
|
-
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
|
|
6119
|
-
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
|
|
6120
|
-
const jam = r() < 0.12;
|
|
6121
|
-
const fired = running && loaded ? (jam ? 3 : 4) : 0;
|
|
6122
|
-
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
|
|
6123
|
-
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
|
|
6124
|
-
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
|
|
6125
|
-
|
|
6126
|
-
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
|
|
6127
|
-
|
|
6128
|
-
if (running && minute % 10 === 0) {
|
|
6129
|
-
/* Hotter shrinks more: the length follows the kiln, so the control
|
|
6130
|
-
chart sees the excursion the trend sees. */
|
|
6131
|
-
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
|
|
6132
|
-
samples.push({ minute, length: Math.round(length * 100) / 100 });
|
|
6133
|
-
}
|
|
6134
|
-
}
|
|
6135
|
-
|
|
6136
|
-
return { readings, samples, batches };
|
|
6137
|
-
}
|
|
6138
|
-
|
|
6139
|
-
/** The readings up to and including the minute. */
|
|
6140
|
-
export function upTo<T extends { minute: number }>(series: readonly T[], minute: number): readonly T[] {
|
|
6141
|
-
return series.filter((one) => one.minute <= minute);
|
|
6142
|
-
}
|
|
6143
|
-
|
|
6144
|
-
/** How long ago the exit pyrometer last reported, in minutes - `0` while it
|
|
6145
|
-
reports. What the tile's freshness is told. */
|
|
6146
|
-
export function exitSilence(p: Plant, minute: number): number {
|
|
6147
|
-
for (let m = minute; m >= 0; m--) {
|
|
6148
|
-
if (p.readings[m]?.exit !== null) return minute - m;
|
|
6149
|
-
}
|
|
6150
|
-
return minute;
|
|
6151
|
-
}
|
|
6152
|
-
|
|
6153
|
-
/** When a condition held, as spans of minutes: `[from, to)`, `to` absent while
|
|
6154
|
-
it still holds at the minute. */
|
|
6155
|
-
function spans(
|
|
6156
|
-
readings: readonly Reading[],
|
|
6157
|
-
minute: number,
|
|
6158
|
-
raise: (one: Reading) => boolean,
|
|
6159
|
-
clear: (one: Reading) => boolean,
|
|
6160
|
-
): { from: number; to?: number }[] {
|
|
6161
|
-
const found: { from: number; to?: number }[] = [];
|
|
6162
|
-
let open: { from: number; to?: number } | null = null;
|
|
6163
|
-
for (const one of readings) {
|
|
6164
|
-
if (one.minute > minute) break;
|
|
6165
|
-
if (open === null && raise(one)) {
|
|
6166
|
-
open = { from: one.minute };
|
|
6167
|
-
found.push(open);
|
|
6168
|
-
} else if (open !== null && clear(one)) {
|
|
6169
|
-
open.to = one.minute;
|
|
6170
|
-
open = null;
|
|
6171
|
-
}
|
|
6172
|
-
}
|
|
6173
|
-
return found;
|
|
6174
|
-
}
|
|
6175
|
-
|
|
6176
|
-
/**
|
|
6177
|
-
* The alarms of the shift as they stand at the minute: raised, resolved - and
|
|
6178
|
-
* acknowledged where the operator did, at the instant he did.
|
|
6179
|
-
*
|
|
6180
|
-
* `start` is the instant the shift began; the alarms carry instants because the
|
|
6181
|
-
* table's model does.
|
|
6182
|
-
*/
|
|
6183
|
-
export function alarmsAt(
|
|
6184
|
-
p: Plant,
|
|
6185
|
-
minute: number,
|
|
6186
|
-
start: number,
|
|
6187
|
-
acknowledged: ReadonlyMap<string, number> = new Map(),
|
|
6188
|
-
): Alarm[] {
|
|
6189
|
-
const instant = (m: number) => start + m * 60_000;
|
|
6190
|
-
const alarms: Alarm[] = [];
|
|
6191
|
-
|
|
6192
|
-
const add = (
|
|
6193
|
-
type: string,
|
|
6194
|
-
span: { from: number; to?: number },
|
|
6195
|
-
extra: { availability?: "suppressed" | "disabled" } = {},
|
|
6196
|
-
) => {
|
|
6197
|
-
alarms.push({
|
|
6198
|
-
id: `${type}-${span.from}`,
|
|
6199
|
-
type,
|
|
6200
|
-
lifecycle: span.to === undefined ? "active-unacknowledged" : "resolved-unacknowledged",
|
|
6201
|
-
raised: instant(span.from),
|
|
6202
|
-
...(span.to === undefined ? {} : { resolved: instant(span.to) }),
|
|
6203
|
-
...extra,
|
|
6204
|
-
});
|
|
6205
|
-
};
|
|
6206
|
-
|
|
6207
|
-
for (const span of spans(p.readings, minute, (one) => one.kiln > KILN_RETURN.limit, (one) => one.kiln <= KILN_RETURN.returnTo)) {
|
|
6208
|
-
add("kiln-high", span);
|
|
6209
|
-
}
|
|
6210
|
-
for (const span of spans(p.readings, minute, (one) => one.exit === null, (one) => one.exit !== null)) {
|
|
6211
|
-
add("exit-silent", span);
|
|
6212
|
-
}
|
|
6213
|
-
/* The belt runs empty because the operator stopped the feed: the plant's
|
|
6214
|
-
logic knows it, so the alarm is suppressed - there, and neutral. */
|
|
6215
|
-
for (const span of spans(p.readings, minute, (one) => !one.running, (one) => one.running)) {
|
|
6216
|
-
add("belt-empty", span, { availability: "suppressed" });
|
|
6217
|
-
}
|
|
6218
|
-
if (minute >= DRYER_FAN_RAISED) {
|
|
6219
|
-
add("dryer-fan", { from: DRYER_FAN_RAISED }, { availability: "disabled" });
|
|
6220
|
-
}
|
|
6221
|
-
|
|
6222
|
-
return alarms.map((alarm) => {
|
|
6223
|
-
const at = acknowledged.get(alarm.id);
|
|
6224
|
-
if (at === undefined) return alarm;
|
|
6225
|
-
const lifecycle = alarm.resolved === undefined ? "active-acknowledged" : "resolved-acknowledged";
|
|
6226
|
-
return { ...alarm, lifecycle, acknowledgedAt: at };
|
|
6227
|
-
});
|
|
6228
|
-
}
|
|
6229
|
-
|
|
6230
|
-
/** The shift's figures for the OEE, up to and including the minute. */
|
|
6231
|
-
export interface ShiftCount {
|
|
6232
|
-
/** Minutes of the shift so far. */
|
|
6233
|
-
planned: number;
|
|
6234
|
-
/** Minutes the belt stood. */
|
|
6235
|
-
downtime: number;
|
|
6236
|
-
/** Tiles out of the kiln. */
|
|
6237
|
-
total: number;
|
|
6238
|
-
/** Of them, inside the tolerance. */
|
|
6239
|
-
good: number;
|
|
6240
|
-
}
|
|
6241
|
-
|
|
6242
|
-
export function countAt(p: Plant, minute: number): ShiftCount {
|
|
6243
|
-
const so = upTo(p.readings, minute);
|
|
6244
|
-
return {
|
|
6245
|
-
planned: so.length,
|
|
6246
|
-
downtime: so.filter((one) => !one.running).length,
|
|
6247
|
-
total: so.reduce((sum, one) => sum + one.fired, 0),
|
|
6248
|
-
good: so.reduce((sum, one) => sum + one.good, 0),
|
|
6249
|
-
};
|
|
6250
|
-
}
|
|
6251
|
-
|
|
6252
|
-
/* ---------------------------------------------------------------------------
|
|
6253
|
-
The machine shop's plan: seven stations, six orders on their way through
|
|
6254
|
-
them on Tuesday, 17 March 2026 - the day the browser suites freeze the
|
|
6255
|
-
clock on.
|
|
6256
|
-
|
|
6257
|
-
Written out, not generated from a seed: a planner reads these as a day's
|
|
6258
|
-
plan, and a finding is placed where it is on purpose - the overlap on the
|
|
6259
|
-
mill, the violated dependency into the paint shop.
|
|
6260
|
-
--------------------------------------------------------------------------- */
|
|
6261
|
-
|
|
6262
|
-
const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
|
|
6263
|
-
const min = (n: number) => n * 60_000;
|
|
6264
|
-
|
|
6265
|
-
export const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)];
|
|
6266
|
-
|
|
6267
|
-
export const STATIONS = [
|
|
6268
|
-
{ id: "saw", label: "Saw 1" },
|
|
6269
|
-
{ id: "lathe-1", label: "Lathe 1" },
|
|
6270
|
-
{ id: "lathe-2", label: "Lathe 2" },
|
|
6271
|
-
{ id: "mill", label: "Mill" },
|
|
6272
|
-
{ id: "press", label: "Press 2" },
|
|
6273
|
-
{ id: "paint", label: "Paint shop" },
|
|
6274
|
-
{ id: "qa", label: "Inspection" },
|
|
6275
|
-
] as const;
|
|
6276
|
-
|
|
6277
|
-
/* Colours in both schemes, as the application's own tokens would give them. */
|
|
6278
|
-
export const ORDERS: readonly Task[] = [
|
|
6279
|
-
{ id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
|
|
6280
|
-
{ id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
|
|
6281
|
-
{ id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
|
|
6282
|
-
{ id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
|
|
6283
|
-
{ id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
|
|
6284
|
-
{ id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
|
|
6285
|
-
];
|
|
6286
|
-
|
|
6287
|
-
export const STEPS: readonly Subtask[] = [
|
|
6288
|
-
{ id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) },
|
|
6289
|
-
{ id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) },
|
|
6290
|
-
{ id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) },
|
|
6291
|
-
|
|
6292
|
-
{ id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) },
|
|
6293
|
-
{ id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) },
|
|
6294
|
-
{ id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) },
|
|
6295
|
-
|
|
6296
|
-
{ id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) },
|
|
6297
|
-
{ id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) },
|
|
6298
|
-
{ id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) },
|
|
6299
|
-
|
|
6300
|
-
{ id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) },
|
|
6301
|
-
{ id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) },
|
|
6302
|
-
{ id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) },
|
|
6303
|
-
|
|
6304
|
-
{ id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) },
|
|
6305
|
-
{ id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) },
|
|
6306
|
-
{ id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) },
|
|
6307
|
-
|
|
6308
|
-
{ id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) },
|
|
6309
|
-
{ id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) },
|
|
6310
|
-
{ id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) },
|
|
6311
|
-
];
|
|
6312
|
-
|
|
6313
|
-
export const MOVES: readonly Dependency[] = [
|
|
6314
|
-
{ id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) },
|
|
6315
|
-
{ id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) },
|
|
6316
|
-
{ id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) },
|
|
6317
|
-
{ id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" },
|
|
6318
|
-
{ id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) },
|
|
6319
|
-
/* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
|
|
6320
|
-
lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */
|
|
6321
|
-
{ id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) },
|
|
6322
|
-
{ id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) },
|
|
6323
|
-
{ id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" },
|
|
6324
|
-
{ id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) },
|
|
6325
|
-
{ id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) },
|
|
6326
|
-
{ id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) },
|
|
6327
|
-
{ id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" },
|
|
6328
|
-
];
|
|
6329
|
-
```
|