@umriss-ui/calculation 0.4.4 → 0.4.6

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/docs/llms-full.md CHANGED
@@ -1,4 +1,4 @@
1
- # @umriss-ui/calculation 0.4.4
1
+ # @umriss-ui/calculation 0.4.6
2
2
 
3
3
  > React calculation view for data-dense applications - a derivation written as it is shown, evaluated by the library, folded and read line by line.
4
4
 
@@ -24,7 +24,76 @@ Built from: Calculation, Tree, Given, Select, Stat, Card, Badge.
24
24
  import { useState } from "react";
25
25
  import { Badge, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Stat, Text } from "@umriss-ui/core";
26
26
  import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
27
- import { DOWNTIME_MINUTES, INCIDENTS, MONTH_MINUTES, SERVICES } from "./operations";
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
+ const MINUTE = 60_000;
32
+
33
+ /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
34
+ const NOW = at(17, 10, 30);
35
+
36
+ interface Service {
37
+ id: string;
38
+ name: string;
39
+ team: string;
40
+ /** 1 is customer-facing and pages at night; 3 waits for the morning. */
41
+ tier: 1 | 2 | 3;
42
+ /** The latency objective: the 95th percentile stays below this, in ms. */
43
+ latencySlo: number;
44
+ /** The availability promised for a month, in per cent. */
45
+ availabilityTarget: number;
46
+ }
47
+
48
+ const SERVICES: readonly Service[] = [
49
+ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
50
+ { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
51
+ { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
52
+ { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
53
+ { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
54
+ { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
55
+ { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
56
+ { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
57
+ ];
58
+
59
+ interface Incident {
60
+ id: string;
61
+ title: string;
62
+ service: string;
63
+ severity: "SEV1" | "SEV2" | "SEV3";
64
+ opened: number;
65
+ acknowledged?: number;
66
+ resolved?: number;
67
+ /** An engineer's id. */
68
+ assignee: string;
69
+ }
70
+
71
+ const INCIDENTS: readonly Incident[] = [
72
+ { 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" },
73
+ { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
74
+ { 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" },
75
+ { 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" },
76
+ { 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" },
77
+ { 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" },
78
+ { 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" },
79
+ { 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" },
80
+ ];
81
+
82
+ /** The month so far: 1 March 00:00 to now, in minutes. */
83
+ const MONTH_MINUTES = (NOW - at(1, 0)) / MINUTE;
84
+
85
+ /** Minutes each service was down this month. With `MONTH_MINUTES` and the
86
+ service's target, what an availability is worked out from. */
87
+ const DOWNTIME_MINUTES: Readonly<Record<string, number>> = {
88
+ checkout: 34,
89
+ billing: 18,
90
+ "sign-in": 41,
91
+ search: 85,
92
+ images: 120,
93
+ notifications: 12,
94
+ webhooks: 210,
95
+ reports: 95,
96
+ };
28
97
 
29
98
  const SEVERITY_TONE = { SEV1: "danger", SEV2: "warning", SEV3: "neutral" } as const;
30
99
 
@@ -139,8 +208,6 @@ export default function ServiceAvailability() {
139
208
  }
140
209
  ```
141
210
 
142
- It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
143
-
144
211
  ### Price a tour
145
212
 
146
213
  A dispatcher at Ferrow Parcel prices a day's tour - energy, driver and vehicle - to see what each stop costs before quoting a customer.
@@ -157,7 +224,149 @@ Built from: Calculation, Tree, Given, Select, Stat, Card, Badge.
157
224
  import { useState } from "react";
158
225
  import { Badge, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Stat, Text } from "@umriss-ui/core";
159
226
  import { Calculation, Given, Product, Quotient, Sum } from "@umriss-ui/calculation";
160
- import { COST_RATES, DEPOTS, DRIVERS, TOURS, VEHICLES } from "./logistics";
227
+
228
+ /* Data from the logistics world, written out here so the example runs on its own. */
229
+
230
+ /** A small LCG - the same numbers on every computer. */
231
+ function random(seed: number): () => number {
232
+ let s = seed >>> 0;
233
+ return () => {
234
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
235
+ return s / 4294967296;
236
+ };
237
+ }
238
+
239
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
240
+ const MINUTE = 60_000;
241
+
242
+ const DEPOTS = [
243
+ { id: "north", name: "North depot" },
244
+ { id: "river", name: "Riverside depot" },
245
+ { id: "east", name: "East Gate depot" },
246
+ ] as const;
247
+
248
+ interface Vehicle {
249
+ id: string;
250
+ plate: string;
251
+ type: "van" | "e-van" | "truck";
252
+ /** Payload, in kg. */
253
+ capacity: number;
254
+ depot: string;
255
+ }
256
+
257
+ const VEHICLES: readonly Vehicle[] = [
258
+ { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
259
+ { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
260
+ { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
261
+ { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
262
+ { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
263
+ { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
264
+ { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
265
+ { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
266
+ ];
267
+
268
+ const DRIVERS = [
269
+ { id: "d1", name: "Martin Hale", depot: "north" },
270
+ { id: "d2", name: "Nadia Petrova", depot: "north" },
271
+ { id: "d3", name: "Owen Carter", depot: "north" },
272
+ { id: "d4", name: "Lucia Romero", depot: "river" },
273
+ { id: "d5", name: "Ben Adeyemi", depot: "river" },
274
+ { id: "d6", name: "Hanna Berg", depot: "east" },
275
+ { id: "d7", name: "Yusuf Demir", depot: "east" },
276
+ { id: "d8", name: "Clara Wendt", depot: "east" },
277
+ ] as const;
278
+
279
+ const CUSTOMERS = [
280
+ "Holloway Garden Supplies",
281
+ "Marlow & Finch Books",
282
+ "Oakridge Pharmacy",
283
+ "Brixley Cycles",
284
+ "Tamsin's Bakery",
285
+ "Northfold Office",
286
+ "Pellham Hardware",
287
+ "Greywick Studio",
288
+ "Juniper Lane Florist",
289
+ "Ashcombe Dental",
290
+ ] as const;
291
+
292
+ interface Stop {
293
+ id: string;
294
+ shipment: string;
295
+ customer: string;
296
+ /** The window the customer was promised, `[from, to]`. */
297
+ window: readonly [number, number];
298
+ /** When the tour plans to be there. */
299
+ arrival: number;
300
+ }
301
+
302
+ interface Tour {
303
+ id: string;
304
+ vehicle: string;
305
+ driver: string;
306
+ depot: string;
307
+ /** Leaves the depot, loaded; back at the depot. */
308
+ from: number;
309
+ to: number;
310
+ /** Minutes of loading before `from`. */
311
+ loading: number;
312
+ km: number;
313
+ stops: readonly Stop[];
314
+ }
315
+
316
+ /** One tour per vehicle. Stops every half hour or so, each with a two-hour
317
+ window; every seventh arrival misses its window - the late ones a
318
+ dispatcher looks for. */
319
+ function tour(index: number): Tour {
320
+ const r = random(700 + index);
321
+ const vehicle = VEHICLES[index]!;
322
+ const truck = vehicle.type === "truck";
323
+ const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
324
+ const count = truck ? 5 : 8 + Math.floor(r() * 4);
325
+ const stops: Stop[] = [];
326
+ let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
327
+ for (let i = 0; i < count; i++) {
328
+ const n = index * 20 + i;
329
+ const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
330
+ /* A late stop's window closed half an hour or more before the arrival. */
331
+ const late = i >= 2 && n % 7 === 3;
332
+ const halfHour = 30 * MINUTE;
333
+ const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
334
+ stops.push({
335
+ id: `s-${index + 1}-${i + 1}`,
336
+ shipment: `FP-${(1_004_210 + n * 13).toString()}`,
337
+ customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
338
+ window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
339
+ arrival,
340
+ });
341
+ arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
342
+ }
343
+ return {
344
+ id: `T-${String(index + 1).padStart(2, "0")}`,
345
+ vehicle: vehicle.id,
346
+ driver: DRIVERS[index]!.id,
347
+ depot: vehicle.depot,
348
+ from,
349
+ to: arrival + 30 * MINUTE,
350
+ loading: truck ? 45 : 30,
351
+ km: Math.round((truck ? 140 : 70) + r() * 40),
352
+ stops,
353
+ };
354
+ }
355
+
356
+ const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
357
+
358
+ /** What a tour costs, per unit - the inputs of a cost per tour. */
359
+ const COST_RATES = {
360
+ /** Diesel, per litre; electricity, per kWh. */
361
+ dieselPerLitre: 1.68,
362
+ electricityPerKwh: 0.31,
363
+ /** Consumption per 100 km. */
364
+ per100Km: { van: 9.5, truck: 24, "e-van": 22 },
365
+ /** A driver's hour, all in. */
366
+ driverPerHour: 34.5,
367
+ /** Lease, insurance and upkeep, per day. */
368
+ vehiclePerDay: { van: 58, truck: 145, "e-van": 72 },
369
+ } as const;
161
370
 
162
371
  const TYPE_NAME = { van: "Van", "e-van": "Electric van", truck: "Truck" } as const;
163
372
  const time = (at: number) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
@@ -266,8 +475,6 @@ export default function PriceATour() {
266
475
  }
267
476
  ```
268
477
 
269
- It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
270
-
271
478
  ### Check an invoice before approving it
272
479
 
273
480
  A cost-centre owner or the finance team at Carrow & Lisle checks what an invoice adds up to - discounts, VAT per rate - before signing it off.
@@ -284,54 +491,159 @@ Built from: Calculation, Tree, Given, Select, Card, Badge, Button, Alert.
284
491
  import { useState } from "react";
285
492
  import { Alert, Badge, Button, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Text } from "@umriss-ui/core";
286
493
  import { Calculation, Difference, Given, Product, Ref, Sum } from "@umriss-ui/calculation";
287
- import { APPROVALS, COST_CENTRES, INVOICES } from "./controlling";
288
- import type { InvoiceLine } from "./controlling";
289
494
 
290
- const WAITING = INVOICES.filter((one) => one.status === "awaiting approval");
291
- const DECISION_TONE = { approved: "success", rejected: "danger", pending: "neutral" } as const;
292
- const date = (at: number) => new Date(at).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
293
- const percent = (rate: number) => `${Math.round(rate * 100)}\u00a0%`;
495
+ /* Data from the controlling world, written out here so the example runs on its own. */
294
496
 
295
- /** One line's net amount: quantity × unit price, times the share paid after a
296
- discount. A function and not a component: the calculation reads its
297
- elements by their type, so a wrapper of one's own would not be recognised. */
298
- function lineNet(line: InvoiceLine, id: string) {
299
- return (
300
- <Product key={id} id={id} label={line.description} unit="€" decimals={2}>
301
- <Given label="Quantity" value={line.quantity} />
302
- <Given label="Unit price" value={line.unitPrice} unit="€" decimals={2} />
303
- {line.discount > 0 && (
304
- <Difference label="Share paid" format="percent">
305
- <Given label="List price" value={1} format="percent" />
306
- <Given label="Discount" value={line.discount} format="percent" source="Framework agreement" />
307
- </Difference>
308
- )}
309
- </Product>
310
- );
497
+ const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();
498
+
499
+ interface CostCentre {
500
+ id: string;
501
+ name: string;
502
+ owner: string;
503
+ /** The budget of one month. */
504
+ monthlyBudget: number;
311
505
  }
312
506
 
313
- export default function CheckAnInvoice() {
314
- const [invoiceId, setInvoiceId] = useState(WAITING[0]!.id);
315
- const [decided, setDecided] = useState<Record<string, "approved" | "rejected">>({});
316
- const invoice = WAITING.find((one) => one.id === invoiceId) ?? WAITING[0]!;
317
- const centre = COST_CENTRES.find((one) => one.id === invoice.costCentre)!;
318
- const approvals = APPROVALS.filter((one) => one.invoice === invoice.id);
319
- const next = approvals.find((one) => one.decision === "pending");
320
- const decision = decided[invoice.id];
507
+ const COST_CENTRES: readonly CostCentre[] = [
508
+ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
509
+ { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
510
+ { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
511
+ { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
512
+ { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
513
+ { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
514
+ { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
515
+ { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
516
+ { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
517
+ ];
321
518
 
322
- const lineIds = invoice.lines.map((_, i) => `line-${i}`);
323
- const rates = [...new Set(invoice.lines.map((line) => line.vatRate))].filter((rate) => rate > 0);
324
- /* The net a rate is charged on: the whole net where every line carries it,
325
- one line on its own, or the sum of the lines that do. */
326
- const netAt = (rate: number) => {
327
- const ids = lineIds.filter((_, i) => invoice.lines[i]!.vatRate === rate);
328
- if (ids.length === lineIds.length) return <Ref to="net" />;
329
- if (ids.length === 1) return <Ref to={ids[0]!} />;
330
- return (
331
- <Sum label={`Net at ${percent(rate)}`} unit="€" decimals={2}>
332
- {ids.map((id) => (
333
- <Ref key={id} to={id} />
334
- ))}
519
+ interface InvoiceLine {
520
+ description: string;
521
+ quantity: number;
522
+ unitPrice: number;
523
+ /** A fraction: 0.1 is ten per cent off. */
524
+ discount: number;
525
+ /** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
526
+ vatRate: number;
527
+ }
528
+
529
+ interface Invoice {
530
+ id: string;
531
+ supplier: string;
532
+ costCentre: string;
533
+ received: number;
534
+ due: number;
535
+ status: "awaiting approval" | "approved" | "paid" | "rejected";
536
+ lines: readonly InvoiceLine[];
537
+ }
538
+
539
+ const INVOICES: readonly Invoice[] = [
540
+ {
541
+ id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
542
+ lines: [
543
+ { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
544
+ { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
545
+ { description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
546
+ ],
547
+ },
548
+ {
549
+ id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
550
+ lines: [
551
+ { description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
552
+ { description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
553
+ ],
554
+ },
555
+ {
556
+ id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
557
+ lines: [
558
+ { description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
559
+ { description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
560
+ ],
561
+ },
562
+ {
563
+ id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
564
+ lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
565
+ },
566
+ {
567
+ id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
568
+ lines: [
569
+ { description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
570
+ { description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
571
+ ],
572
+ },
573
+ {
574
+ id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
575
+ lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
576
+ },
577
+ ];
578
+
579
+ interface Approval {
580
+ invoice: string;
581
+ step: "cost centre" | "finance";
582
+ approver: string;
583
+ decision: "approved" | "rejected" | "pending";
584
+ at?: number;
585
+ comment?: string;
586
+ }
587
+
588
+ /** Two signatures an invoice needs: its cost centre's owner, then finance. */
589
+ const APPROVALS: readonly Approval[] = [
590
+ { invoice: "INV-26-0318", step: "cost centre", approver: "Olga Ivanova", decision: "pending" },
591
+ { invoice: "INV-26-0317", step: "cost centre", approver: "Rafael Ortiz", decision: "approved", at: on(3, 16) },
592
+ { invoice: "INV-26-0317", step: "finance", approver: "Martina Vogel", decision: "pending" },
593
+ { invoice: "INV-26-0309", step: "cost centre", approver: "Kenji Arai", decision: "approved", at: on(3, 10) },
594
+ { invoice: "INV-26-0309", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 11) },
595
+ { invoice: "INV-26-0302", step: "cost centre", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
596
+ { invoice: "INV-26-0302", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
597
+ { invoice: "INV-26-0226", step: "cost centre", approver: "Helen Marsh", decision: "approved", at: on(2, 27) },
598
+ { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
599
+ { invoice: "INV-26-0221", step: "cost centre", approver: "Anika Sørensen", decision: "rejected", at: on(2, 24), comment: "Not ordered - the review was cancelled in January." },
600
+ ];
601
+
602
+ const WAITING = INVOICES.filter((one) => one.status === "awaiting approval");
603
+ const DECISION_TONE = { approved: "success", rejected: "danger", pending: "neutral" } as const;
604
+ const date = (at: number) => new Date(at).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
605
+ const percent = (rate: number) => `${Math.round(rate * 100)}\u00a0%`;
606
+
607
+ /** One line's net amount: quantity × unit price, times the share paid after a
608
+ discount. A function and not a component: the calculation reads its
609
+ elements by their type, so a wrapper of one's own would not be recognised. */
610
+ function lineNet(line: InvoiceLine, id: string) {
611
+ return (
612
+ <Product key={id} id={id} label={line.description} unit="€" decimals={2}>
613
+ <Given label="Quantity" value={line.quantity} />
614
+ <Given label="Unit price" value={line.unitPrice} unit="€" decimals={2} />
615
+ {line.discount > 0 && (
616
+ <Difference label="Share paid" format="percent">
617
+ <Given label="List price" value={1} format="percent" />
618
+ <Given label="Discount" value={line.discount} format="percent" source="Framework agreement" />
619
+ </Difference>
620
+ )}
621
+ </Product>
622
+ );
623
+ }
624
+
625
+ export default function CheckAnInvoice() {
626
+ const [invoiceId, setInvoiceId] = useState(WAITING[0]!.id);
627
+ const [decided, setDecided] = useState<Record<string, "approved" | "rejected">>({});
628
+ const invoice = WAITING.find((one) => one.id === invoiceId) ?? WAITING[0]!;
629
+ const centre = COST_CENTRES.find((one) => one.id === invoice.costCentre)!;
630
+ const approvals = APPROVALS.filter((one) => one.invoice === invoice.id);
631
+ const next = approvals.find((one) => one.decision === "pending");
632
+ const decision = decided[invoice.id];
633
+
634
+ const lineIds = invoice.lines.map((_, i) => `line-${i}`);
635
+ const rates = [...new Set(invoice.lines.map((line) => line.vatRate))].filter((rate) => rate > 0);
636
+ /* The net a rate is charged on: the whole net where every line carries it,
637
+ one line on its own, or the sum of the lines that do. */
638
+ const netAt = (rate: number) => {
639
+ const ids = lineIds.filter((_, i) => invoice.lines[i]!.vatRate === rate);
640
+ if (ids.length === lineIds.length) return <Ref to="net" />;
641
+ if (ids.length === 1) return <Ref to={ids[0]!} />;
642
+ return (
643
+ <Sum label={`Net at ${percent(rate)}`} unit="€" decimals={2}>
644
+ {ids.map((id) => (
645
+ <Ref key={id} to={id} />
646
+ ))}
335
647
  </Sum>
336
648
  );
337
649
  };
@@ -430,8 +742,6 @@ export default function CheckAnInvoice() {
430
742
  }
431
743
  ```
432
744
 
433
- It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
434
-
435
745
  ### Explain the shift's OEE
436
746
 
437
747
  A shift lead at Brenholt Tile Works explains at the 10:30 handover why the kiln line's OEE stands where it stands.
@@ -446,99 +756,256 @@ Built from: Calculation, Tree, Given, Stat, Card, Alert.
446
756
  ```tsx
447
757
  import { Alert, Card, CardBody, CardHeader, Grid, Stack, Stat, Text } from "@umriss-ui/core";
448
758
  import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
449
- import { IDEAL_CYCLE_MINUTES, KILN, countAt, plant } from "./plant";
450
-
451
- /* The early shift starts at 06:00; 10:30 is minute 270 into it, so the
452
- minutes 0 to 269 have passed. */
453
- const SHIFT = plant(17);
454
- const MINUTE = 270;
455
- const count = countAt(SHIFT, MINUTE - 1);
456
- const stopped = SHIFT.readings.filter((one) => one.minute < MINUTE && !one.running).map((one) => one.minute);
457
- const clock = (minute: number) => `${String(6 + Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
458
-
459
- const OEE_TARGET = 0.85;
460
- const OEE_LIMITS = [
461
- { value: 0.75, side: "lower", severity: "warning" },
462
- { value: 0.65, side: "lower", severity: "alarm" },
463
- ] as const;
464
-
465
- export default function ExplainTheOee() {
466
- return (
467
- <Stack gap={4}>
468
- <Grid minItemWidth="180px" gap={4} data-callout="1">
469
- <Stat label="Planned so far" value={count.planned} unit="min" />
470
- <Stat label="Belt stopped" value={count.downtime} unit="min" />
471
- <Stat label="Tiles out of the kiln" value={count.total} unit="pcs" />
472
- <Stat label="Of them good" value={count.good} unit="pcs" />
473
- </Grid>
474
759
 
475
- {stopped.length > 0 && (
476
- <Alert tone="warning" title={`Feed stopped ${clock(stopped[0]!)} to ${clock(stopped.at(-1)! + 1)}`} data-callout="2">
477
- The kiln's zone 3 went above {KILN.alarm} °C; tiles fired outside {KILN.tolerance[0]} to {KILN.tolerance[1]} °C
478
- count as scrap.
479
- </Alert>
480
- )}
760
+ /* Data from the plant world, written out here so the example runs on its own. */
481
761
 
482
- <Card>
483
- <CardHeader title={`OEE, early shift up to ${clock(MINUTE)}`} />
484
- <CardBody>
485
- <Stack gap={2}>
486
- <div data-callout="3">
487
- <Calculation aria-label="OEE of the early shift so far">
488
- <Product label="OEE" format="percent" target={OEE_TARGET} limits={OEE_LIMITS}>
489
- <Quotient label="Availability" format="percent" explanation="Lost to stops of the belt.">
490
- <Difference id="runtime" label="Run time" unit="min">
491
- <Given id="planned" label="Planned production time" value={count.planned} unit="min" source="Shift calendar" />
492
- <Given label="Downtime" value={count.downtime} unit="min" source="Belt drive" />
493
- </Difference>
494
- <Ref to="planned" />
495
- </Quotient>
496
- <Quotient label="Performance" format="percent" explanation="Lost to jams and slow cycles.">
497
- <Product label="Ideal run time" unit="min">
498
- <Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/pc" source="Kiln K1 rating" />
499
- <Given id="total" label="Total count" value={count.total} unit="pcs" source="Exit counter" />
500
- </Product>
501
- <Ref to="runtime" />
502
- </Quotient>
503
- <Quotient label="Quality" format="percent" explanation="Lost to scrap fired outside the tolerance.">
504
- <Given label="Good count" value={count.good} unit="pcs" source="Exit counter" />
505
- <Ref to="total" />
506
- </Quotient>
507
- </Product>
508
- </Calculation>
509
- </div>
510
- <Text size="sm" tone="muted" data-callout="4">
511
- Hover a line: its operands and every place it is used light up.
512
- </Text>
513
- </Stack>
514
- </CardBody>
515
- </Card>
516
- </Stack>
517
- );
762
+ /** A small LCG - reproducible across runs and platforms. */
763
+ function random(seed: number): () => number {
764
+ let s = seed >>> 0;
765
+ return () => {
766
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
767
+ return s / 4294967296;
768
+ };
518
769
  }
519
- ```
520
770
 
521
- It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
522
-
523
- ## Getting started
771
+ /** The early shift, 06:00 to 14:00. */
772
+ const SHIFT_MINUTES = 480;
524
773
 
525
- What a calculation is for, and what an application sets up before the first one.
774
+ /** The kiln's zone 3, in °C: the numbers every part reads it against, once.
775
+ Between the two warnings lies the tolerance - a tile fired outside it is
776
+ scrap; `returnTo` is the alarm's dead band. */
777
+ const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
526
778
 
527
- ### Installation
779
+ /** The kiln fires one tile every quarter of a minute when nothing holds it up -
780
+ the ideal cycle time of the OEE's performance. */
781
+ const IDEAL_CYCLE_MINUTES = 0.25;
528
782
 
529
- Show a figure together with how it came about (a derivation, a statement of account): the operator before each number, the result above a double rule, and every figure opening into the numbers it came from.
783
+ /** One minute of the line. */
784
+ interface Reading {
785
+ /** Minutes since the start of the shift. */
786
+ minute: number;
787
+ /** Zone 3 of the kiln, in °C. */
788
+ kiln: number;
789
+ /** The exit pyrometer, in °C - `null` while it sends nothing. */
790
+ exit: number | null;
791
+ /** Whether the belt runs. */
792
+ running: boolean;
793
+ /** Tiles out of the kiln in this minute. */
794
+ fired: number;
795
+ /** Of them, the ones fired inside the tolerance. */
796
+ good: number;
797
+ }
530
798
 
531
- ```ts
532
- import { Calculation, Given, Difference, Quotient, Ref } from "@umriss-ui/calculation";
533
- ```
799
+ /** A tile taken off the belt and measured - one every ten minutes while it runs. */
800
+ interface Sample {
801
+ minute: number;
802
+ /** The tile's length after firing, in mm (nominal 600). */
803
+ length: number;
804
+ }
534
805
 
535
- Demo page: https://romanhaendler.github.io/umriss-ui/calculation/installation/
806
+ /** A batch of tiles through the three stations. */
807
+ interface Batch {
808
+ id: string;
809
+ name: string;
810
+ /** How many tiles the batch is planned for. */
811
+ tiles: number;
812
+ /** Minutes since the start of the shift, per station. */
813
+ press: readonly [number, number];
814
+ dryer: readonly [number, number];
815
+ kiln: readonly [number, number];
816
+ }
536
817
 
537
- Install with `pnpm add @umriss-ui/calculation @umriss-ui/core`; core is a peer dependency and brings formats, wording and the assessment of targets and limits. React 18 or 19 as a peer as well.
818
+ interface Plant {
819
+ readings: readonly Reading[];
820
+ samples: readonly Sample[];
821
+ batches: readonly Batch[];
822
+ }
538
823
 
539
- The stylesheet loads itself: the package imports it and marks CSS as a side effect, so a bundler keeps it. `@umriss-ui/calculation/styles.css` stays exported for setups that link stylesheets by hand.
824
+ /* When the exit pyrometer goes silent, and for how long. Fixed rather than
825
+ seeded, so that a running control room shows its reading turn stale, then
826
+ lost, and come back. */
827
+ const SILENT_FROM = 288;
828
+ const SILENT_FOR = 50;
540
829
 
541
- You write the figure as elements and hand in only the givens; the calculation performs every operation it shows, so the screen cannot disagree with the number. Start with [Calculation](#/calculation).
830
+ /** The shift, from a seed. */
831
+ function plant(seed: number): Plant {
832
+ const r = random(seed);
833
+
834
+ /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
835
+ into the shift, for half an hour, peaking near 1242 °C. The operator stops
836
+ the feed twenty-two minutes in, for eighteen minutes. The noise around it
837
+ is too small to reach a warning on its own, so this is the only crossing -
838
+ which is what the tests hold. */
839
+ const surgeFrom = 150 + Math.floor(r() * 60);
840
+ const surgeFor = 30;
841
+ const stopFrom = surgeFrom + 22;
842
+ const stopFor = 18;
843
+
844
+ /* The plan: batches through the kiln one after another, each pressed an
845
+ hour and dried half an hour before - the first ones during the night
846
+ shift. Tiles come out of the kiln only while a batch is in it, so the
847
+ OEE's count and the batches' counts are the same tiles. The plan is a
848
+ plan: a stop of the belt does not move it. */
849
+ const batches: Batch[] = [];
850
+ for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
851
+ const length = 70 + Math.floor(r() * 25);
852
+ batches.push({
853
+ id: `b-${4121 + i}`,
854
+ name: `B-${4121 + i}`,
855
+ tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
856
+ press: [at - 60, at - 60 + length],
857
+ dryer: [at - 30, at - 30 + length],
858
+ kiln: [at, at + length],
859
+ });
860
+ at += length + 5;
861
+ }
862
+
863
+ const readings: Reading[] = [];
864
+ const samples: Sample[] = [];
865
+ let drift = 0;
866
+ for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
867
+ drift = 0.9 * drift + (r() - 0.5) * 3;
868
+ const into = minute - surgeFrom;
869
+ const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
870
+ const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
871
+
872
+ const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
873
+ const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
874
+ const jam = r() < 0.12;
875
+ const fired = running && loaded ? (jam ? 3 : 4) : 0;
876
+ const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
877
+ const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
878
+ const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
879
+
880
+ readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
881
+
882
+ if (running && minute % 10 === 0) {
883
+ /* Hotter shrinks more: the length follows the kiln, so the control
884
+ chart sees the excursion the trend sees. */
885
+ const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
886
+ samples.push({ minute, length: Math.round(length * 100) / 100 });
887
+ }
888
+ }
889
+
890
+ return { readings, samples, batches };
891
+ }
892
+
893
+ /** The readings up to and including the minute. */
894
+ function upTo<T extends { minute: number }>(series: readonly T[], minute: number): readonly T[] {
895
+ return series.filter((one) => one.minute <= minute);
896
+ }
897
+
898
+ /** The shift's figures for the OEE, up to and including the minute. */
899
+ interface ShiftCount {
900
+ /** Minutes of the shift so far. */
901
+ planned: number;
902
+ /** Minutes the belt stood. */
903
+ downtime: number;
904
+ /** Tiles out of the kiln. */
905
+ total: number;
906
+ /** Of them, inside the tolerance. */
907
+ good: number;
908
+ }
909
+
910
+ function countAt(p: Plant, minute: number): ShiftCount {
911
+ const so = upTo(p.readings, minute);
912
+ return {
913
+ planned: so.length,
914
+ downtime: so.filter((one) => !one.running).length,
915
+ total: so.reduce((sum, one) => sum + one.fired, 0),
916
+ good: so.reduce((sum, one) => sum + one.good, 0),
917
+ };
918
+ }
919
+
920
+ /* The early shift starts at 06:00; 10:30 is minute 270 into it, so the
921
+ minutes 0 to 269 have passed. */
922
+ const SHIFT = plant(17);
923
+ const MINUTE = 270;
924
+ const count = countAt(SHIFT, MINUTE - 1);
925
+ const stopped = SHIFT.readings.filter((one) => one.minute < MINUTE && !one.running).map((one) => one.minute);
926
+ const clock = (minute: number) => `${String(6 + Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
927
+
928
+ const OEE_TARGET = 0.85;
929
+ const OEE_LIMITS = [
930
+ { value: 0.75, side: "lower", severity: "warning" },
931
+ { value: 0.65, side: "lower", severity: "alarm" },
932
+ ] as const;
933
+
934
+ export default function ExplainTheOee() {
935
+ return (
936
+ <Stack gap={4}>
937
+ <Grid minItemWidth="180px" gap={4} data-callout="1">
938
+ <Stat label="Planned so far" value={count.planned} unit="min" />
939
+ <Stat label="Belt stopped" value={count.downtime} unit="min" />
940
+ <Stat label="Tiles out of the kiln" value={count.total} unit="pcs" />
941
+ <Stat label="Of them good" value={count.good} unit="pcs" />
942
+ </Grid>
943
+
944
+ {stopped.length > 0 && (
945
+ <Alert tone="warning" title={`Feed stopped ${clock(stopped[0]!)} to ${clock(stopped.at(-1)! + 1)}`} data-callout="2">
946
+ The kiln's zone 3 went above {KILN.alarm} °C; tiles fired outside {KILN.tolerance[0]} to {KILN.tolerance[1]} °C
947
+ count as scrap.
948
+ </Alert>
949
+ )}
950
+
951
+ <Card>
952
+ <CardHeader title={`OEE, early shift up to ${clock(MINUTE)}`} />
953
+ <CardBody>
954
+ <Stack gap={2}>
955
+ <div data-callout="3">
956
+ <Calculation aria-label="OEE of the early shift so far">
957
+ <Product label="OEE" format="percent" target={OEE_TARGET} limits={OEE_LIMITS}>
958
+ <Quotient label="Availability" format="percent" explanation="Lost to stops of the belt.">
959
+ <Difference id="runtime" label="Run time" unit="min">
960
+ <Given id="planned" label="Planned production time" value={count.planned} unit="min" source="Shift calendar" />
961
+ <Given label="Downtime" value={count.downtime} unit="min" source="Belt drive" />
962
+ </Difference>
963
+ <Ref to="planned" />
964
+ </Quotient>
965
+ <Quotient label="Performance" format="percent" explanation="Lost to jams and slow cycles.">
966
+ <Product label="Ideal run time" unit="min">
967
+ <Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/pc" source="Kiln K1 rating" />
968
+ <Given id="total" label="Total count" value={count.total} unit="pcs" source="Exit counter" />
969
+ </Product>
970
+ <Ref to="runtime" />
971
+ </Quotient>
972
+ <Quotient label="Quality" format="percent" explanation="Lost to scrap fired outside the tolerance.">
973
+ <Given label="Good count" value={count.good} unit="pcs" source="Exit counter" />
974
+ <Ref to="total" />
975
+ </Quotient>
976
+ </Product>
977
+ </Calculation>
978
+ </div>
979
+ <Text size="sm" tone="muted" data-callout="4">
980
+ Hover a line: its operands and every place it is used light up.
981
+ </Text>
982
+ </Stack>
983
+ </CardBody>
984
+ </Card>
985
+ </Stack>
986
+ );
987
+ }
988
+ ```
989
+
990
+ ## Getting started
991
+
992
+ What a calculation is for, and what an application sets up before the first one.
993
+
994
+ ### Installation
995
+
996
+ Show a figure together with how it came about (a derivation, a statement of account): the operator before each number, the result above a double rule, and every figure opening into the numbers it came from.
997
+
998
+ ```ts
999
+ import { Calculation, Given, Difference, Quotient, Ref } from "@umriss-ui/calculation";
1000
+ ```
1001
+
1002
+ Demo page: https://romanhaendler.github.io/umriss-ui/calculation/installation/
1003
+
1004
+ Install with `pnpm add @umriss-ui/calculation @umriss-ui/core`; core is a peer dependency and brings formats, wording and the assessment of targets and limits. React 18 or 19 as a peer as well.
1005
+
1006
+ The stylesheet loads itself: the package imports it and marks CSS as a side effect, so a bundler keeps it. `@umriss-ui/calculation/styles.css` stays exported for setups that link stylesheets by hand.
1007
+
1008
+ You write the figure as elements and hand in only the givens; the calculation performs every operation it shows, so the screen cannot disagree with the number. Start with [Calculation](#/calculation).
542
1009
 
543
1010
  #### Examples
544
1011
 
@@ -610,73 +1077,230 @@ export default function FirstSum() {
610
1077
  Nest operators for a figure made of factors: each folds with its formula under its name, and `Ref` stands for a quantity used twice.
611
1078
 
612
1079
  ```tsx
613
- import { countAt, IDEAL_CYCLE_MINUTES, plant, SHIFT_MINUTES } from "./plant";
614
1080
  import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
615
1081
 
616
- const COUNT = countAt(plant(17), SHIFT_MINUTES);
1082
+ /* Data from the plant world, written out here so the example runs on its own. */
617
1083
 
618
- export default function Oee() {
619
- return (
620
- <Calculation aria-label="OEE of the kiln line, early shift">
621
- <Product label="OEE" format="percent" target={0.85}>
622
- <Quotient label="Availability" format="percent">
623
- <Difference id="runtime" label="Run time" unit="min">
624
- <Given id="planned" label="Planned production time" value={COUNT.planned} unit="min" />
625
- <Given label="Downtime" value={COUNT.downtime} unit="min" />
626
- </Difference>
627
- <Ref to="planned" />
628
- </Quotient>
629
- <Quotient label="Performance" format="percent">
630
- <Product label="Ideal run time" unit="min">
631
- <Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/tile" />
632
- <Given id="total" label="Tiles fired" value={COUNT.total} unit="tiles" />
633
- </Product>
634
- <Ref to="runtime" />
635
- </Quotient>
636
- <Quotient label="Quality" format="percent">
637
- <Given label="Good tiles" value={COUNT.good} unit="tiles" />
638
- <Ref to="total" />
639
- </Quotient>
640
- </Product>
641
- </Calculation>
642
- );
1084
+ /** A small LCG - reproducible across runs and platforms. */
1085
+ function random(seed: number): () => number {
1086
+ let s = seed >>> 0;
1087
+ return () => {
1088
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
1089
+ return s / 4294967296;
1090
+ };
643
1091
  }
644
- ```
645
1092
 
646
- It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1093
+ /** The early shift, 06:00 to 14:00. */
1094
+ const SHIFT_MINUTES = 480;
647
1095
 
648
- ##### Write a chain
1096
+ /** The kiln's zone 3, in °C: the numbers every part reads it against, once.
1097
+ Between the two warnings lies the tolerance - a tile fired outside it is
1098
+ scrap; `returnTo` is the alarm's dead band. */
1099
+ const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
649
1100
 
650
- Put lines in a `Chain` for a sheet read top to bottom; each works into the value before it, and an `Interim` names that value.
1101
+ /** The kiln fires one tile every quarter of a minute when nothing holds it up -
1102
+ the ideal cycle time of the OEE's performance. */
1103
+ const IDEAL_CYCLE_MINUTES = 0.25;
651
1104
 
652
- ```tsx
653
- import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref } from "@umriss-ui/calculation";
1105
+ /** One minute of the line. */
1106
+ interface Reading {
1107
+ /** Minutes since the start of the shift. */
1108
+ minute: number;
1109
+ /** Zone 3 of the kiln, in °C. */
1110
+ kiln: number;
1111
+ /** The exit pyrometer, in °C - `null` while it sends nothing. */
1112
+ exit: number | null;
1113
+ /** Whether the belt runs. */
1114
+ running: boolean;
1115
+ /** Tiles out of the kiln in this minute. */
1116
+ fired: number;
1117
+ /** Of them, the ones fired inside the tolerance. */
1118
+ good: number;
1119
+ }
654
1120
 
655
- export default function Invoice() {
656
- return (
657
- <Calculation aria-label="Invoice INV-26-0318, Brandlow Office Supply">
658
- <Chain>
659
- <Product id="lamps" label="Desk lamps, LED" unit="€" decimals={2}>
660
- <Given label="Lamps" value={24} unit="pcs" />
661
- <Given label="Price per lamp" value={48.9} unit="€/pc" decimals={2} />
662
- </Product>
663
- <Minus>
664
- <Product label="Discount" unit="€" decimals={2}>
665
- <Given label="Discount on lamps" value={0.1} format="percent" />
666
- <Ref to="lamps" />
667
- </Product>
668
- </Minus>
669
- <Plus>
670
- <Product label="Printer paper" unit="€" decimals={2}>
671
- <Given label="Boxes" value={40} unit="boxes" />
672
- <Given label="Price per box" value={21.5} unit="€/box" decimals={2} />
673
- </Product>
674
- </Plus>
675
- <Plus label="Delivery" value={35} unit="€" decimals={2} />
676
- <Interim id="net" label="Net amount" unit="€" decimals={2} />
677
- <Plus>
678
- <Product label="VAT" unit="€" decimals={2}>
679
- <Given label="VAT rate" value={0.19} format="percent" />
1121
+ /** A tile taken off the belt and measured - one every ten minutes while it runs. */
1122
+ interface Sample {
1123
+ minute: number;
1124
+ /** The tile's length after firing, in mm (nominal 600). */
1125
+ length: number;
1126
+ }
1127
+
1128
+ /** A batch of tiles through the three stations. */
1129
+ interface Batch {
1130
+ id: string;
1131
+ name: string;
1132
+ /** How many tiles the batch is planned for. */
1133
+ tiles: number;
1134
+ /** Minutes since the start of the shift, per station. */
1135
+ press: readonly [number, number];
1136
+ dryer: readonly [number, number];
1137
+ kiln: readonly [number, number];
1138
+ }
1139
+
1140
+ interface Plant {
1141
+ readings: readonly Reading[];
1142
+ samples: readonly Sample[];
1143
+ batches: readonly Batch[];
1144
+ }
1145
+
1146
+ /* When the exit pyrometer goes silent, and for how long. Fixed rather than
1147
+ seeded, so that a running control room shows its reading turn stale, then
1148
+ lost, and come back. */
1149
+ const SILENT_FROM = 288;
1150
+ const SILENT_FOR = 50;
1151
+
1152
+ /** The shift, from a seed. */
1153
+ function plant(seed: number): Plant {
1154
+ const r = random(seed);
1155
+
1156
+ /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
1157
+ into the shift, for half an hour, peaking near 1242 °C. The operator stops
1158
+ the feed twenty-two minutes in, for eighteen minutes. The noise around it
1159
+ is too small to reach a warning on its own, so this is the only crossing -
1160
+ which is what the tests hold. */
1161
+ const surgeFrom = 150 + Math.floor(r() * 60);
1162
+ const surgeFor = 30;
1163
+ const stopFrom = surgeFrom + 22;
1164
+ const stopFor = 18;
1165
+
1166
+ /* The plan: batches through the kiln one after another, each pressed an
1167
+ hour and dried half an hour before - the first ones during the night
1168
+ shift. Tiles come out of the kiln only while a batch is in it, so the
1169
+ OEE's count and the batches' counts are the same tiles. The plan is a
1170
+ plan: a stop of the belt does not move it. */
1171
+ const batches: Batch[] = [];
1172
+ for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
1173
+ const length = 70 + Math.floor(r() * 25);
1174
+ batches.push({
1175
+ id: `b-${4121 + i}`,
1176
+ name: `B-${4121 + i}`,
1177
+ tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
1178
+ press: [at - 60, at - 60 + length],
1179
+ dryer: [at - 30, at - 30 + length],
1180
+ kiln: [at, at + length],
1181
+ });
1182
+ at += length + 5;
1183
+ }
1184
+
1185
+ const readings: Reading[] = [];
1186
+ const samples: Sample[] = [];
1187
+ let drift = 0;
1188
+ for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
1189
+ drift = 0.9 * drift + (r() - 0.5) * 3;
1190
+ const into = minute - surgeFrom;
1191
+ const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
1192
+ const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
1193
+
1194
+ const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
1195
+ const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
1196
+ const jam = r() < 0.12;
1197
+ const fired = running && loaded ? (jam ? 3 : 4) : 0;
1198
+ const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
1199
+ const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
1200
+ const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
1201
+
1202
+ readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
1203
+
1204
+ if (running && minute % 10 === 0) {
1205
+ /* Hotter shrinks more: the length follows the kiln, so the control
1206
+ chart sees the excursion the trend sees. */
1207
+ const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
1208
+ samples.push({ minute, length: Math.round(length * 100) / 100 });
1209
+ }
1210
+ }
1211
+
1212
+ return { readings, samples, batches };
1213
+ }
1214
+
1215
+ /** The readings up to and including the minute. */
1216
+ function upTo<T extends { minute: number }>(series: readonly T[], minute: number): readonly T[] {
1217
+ return series.filter((one) => one.minute <= minute);
1218
+ }
1219
+
1220
+ /** The shift's figures for the OEE, up to and including the minute. */
1221
+ interface ShiftCount {
1222
+ /** Minutes of the shift so far. */
1223
+ planned: number;
1224
+ /** Minutes the belt stood. */
1225
+ downtime: number;
1226
+ /** Tiles out of the kiln. */
1227
+ total: number;
1228
+ /** Of them, inside the tolerance. */
1229
+ good: number;
1230
+ }
1231
+
1232
+ function countAt(p: Plant, minute: number): ShiftCount {
1233
+ const so = upTo(p.readings, minute);
1234
+ return {
1235
+ planned: so.length,
1236
+ downtime: so.filter((one) => !one.running).length,
1237
+ total: so.reduce((sum, one) => sum + one.fired, 0),
1238
+ good: so.reduce((sum, one) => sum + one.good, 0),
1239
+ };
1240
+ }
1241
+
1242
+ const COUNT = countAt(plant(17), SHIFT_MINUTES);
1243
+
1244
+ export default function Oee() {
1245
+ return (
1246
+ <Calculation aria-label="OEE of the kiln line, early shift">
1247
+ <Product label="OEE" format="percent" target={0.85}>
1248
+ <Quotient label="Availability" format="percent">
1249
+ <Difference id="runtime" label="Run time" unit="min">
1250
+ <Given id="planned" label="Planned production time" value={COUNT.planned} unit="min" />
1251
+ <Given label="Downtime" value={COUNT.downtime} unit="min" />
1252
+ </Difference>
1253
+ <Ref to="planned" />
1254
+ </Quotient>
1255
+ <Quotient label="Performance" format="percent">
1256
+ <Product label="Ideal run time" unit="min">
1257
+ <Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/tile" />
1258
+ <Given id="total" label="Tiles fired" value={COUNT.total} unit="tiles" />
1259
+ </Product>
1260
+ <Ref to="runtime" />
1261
+ </Quotient>
1262
+ <Quotient label="Quality" format="percent">
1263
+ <Given label="Good tiles" value={COUNT.good} unit="tiles" />
1264
+ <Ref to="total" />
1265
+ </Quotient>
1266
+ </Product>
1267
+ </Calculation>
1268
+ );
1269
+ }
1270
+ ```
1271
+
1272
+ ##### Write a chain
1273
+
1274
+ Put lines in a `Chain` for a sheet read top to bottom; each works into the value before it, and an `Interim` names that value.
1275
+
1276
+ ```tsx
1277
+ import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref } from "@umriss-ui/calculation";
1278
+
1279
+ export default function Invoice() {
1280
+ return (
1281
+ <Calculation aria-label="Invoice INV-26-0318, Brandlow Office Supply">
1282
+ <Chain>
1283
+ <Product id="lamps" label="Desk lamps, LED" unit="€" decimals={2}>
1284
+ <Given label="Lamps" value={24} unit="pcs" />
1285
+ <Given label="Price per lamp" value={48.9} unit="€/pc" decimals={2} />
1286
+ </Product>
1287
+ <Minus>
1288
+ <Product label="Discount" unit="€" decimals={2}>
1289
+ <Given label="Discount on lamps" value={0.1} format="percent" />
1290
+ <Ref to="lamps" />
1291
+ </Product>
1292
+ </Minus>
1293
+ <Plus>
1294
+ <Product label="Printer paper" unit="€" decimals={2}>
1295
+ <Given label="Boxes" value={40} unit="boxes" />
1296
+ <Given label="Price per box" value={21.5} unit="€/box" decimals={2} />
1297
+ </Product>
1298
+ </Plus>
1299
+ <Plus label="Delivery" value={35} unit="€" decimals={2} />
1300
+ <Interim id="net" label="Net amount" unit="€" decimals={2} />
1301
+ <Plus>
1302
+ <Product label="VAT" unit="€" decimals={2}>
1303
+ <Given label="VAT rate" value={0.19} format="percent" />
680
1304
  <Ref to="net" />
681
1305
  </Product>
682
1306
  </Plus>
@@ -818,9 +1442,55 @@ export default function UsedTwice() {
818
1442
  `.map` inside an operator works; folded, a line with more than four operands says how many it holds instead of a long formula.
819
1443
 
820
1444
  ```tsx
821
- import { DOWNTIME_MINUTES, MONTH_MINUTES, SERVICES } from "./operations";
822
1445
  import { Calculation, Difference, Given, Product, Quotient } from "@umriss-ui/calculation";
823
1446
 
1447
+ /* Data from the operations world, written out here so the example runs on its own. */
1448
+
1449
+ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
1450
+ const MINUTE = 60_000;
1451
+
1452
+ /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
1453
+ const NOW = at(17, 10, 30);
1454
+
1455
+ interface Service {
1456
+ id: string;
1457
+ name: string;
1458
+ team: string;
1459
+ /** 1 is customer-facing and pages at night; 3 waits for the morning. */
1460
+ tier: 1 | 2 | 3;
1461
+ /** The latency objective: the 95th percentile stays below this, in ms. */
1462
+ latencySlo: number;
1463
+ /** The availability promised for a month, in per cent. */
1464
+ availabilityTarget: number;
1465
+ }
1466
+
1467
+ const SERVICES: readonly Service[] = [
1468
+ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
1469
+ { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
1470
+ { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
1471
+ { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
1472
+ { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
1473
+ { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
1474
+ { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
1475
+ { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
1476
+ ];
1477
+
1478
+ /** The month so far: 1 March 00:00 to now, in minutes. */
1479
+ const MONTH_MINUTES = (NOW - at(1, 0)) / MINUTE;
1480
+
1481
+ /** Minutes each service was down this month. With `MONTH_MINUTES` and the
1482
+ service's target, what an availability is worked out from. */
1483
+ const DOWNTIME_MINUTES: Readonly<Record<string, number>> = {
1484
+ checkout: 34,
1485
+ billing: 18,
1486
+ "sign-in": 41,
1487
+ search: 85,
1488
+ images: 120,
1489
+ notifications: 12,
1490
+ webhooks: 210,
1491
+ reports: 95,
1492
+ };
1493
+
824
1494
  /* The services a booking passes through, in order. */
825
1495
  const PATH = ["sign-in", "search", "checkout", "billing", "notifications"];
826
1496
 
@@ -843,8 +1513,6 @@ export default function FromData() {
843
1513
  }
844
1514
  ```
845
1515
 
846
- It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
847
-
848
1516
  ##### Hold a figure to its limits
849
1517
 
850
1518
  `limits` are assessed through core's `assess()`; the verdict stands as a word beneath the number, never as a colour alone.
@@ -1327,10 +1995,33 @@ Where the figures leave the labels less room than they need, and less than 10 re
1327
1995
  `metrics` puts several numbers on every line, each in its own column; every `value` is then an object with a number per metric.
1328
1996
 
1329
1997
  ```tsx
1330
- import { PEOPLE } from "./planning";
1331
1998
  import { Calculation, Given, Sum } from "@umriss-ui/calculation";
1332
1999
  import type { Metric } from "@umriss-ui/calculation";
1333
2000
 
2001
+ /* Data from the planning world, written out here so the example runs on its own. */
2002
+
2003
+ interface Person {
2004
+ id: string;
2005
+ name: string;
2006
+ role: "Developer" | "Designer" | "Product manager" | "QA engineer";
2007
+ team: "Web" | "Apps";
2008
+ /** Hours a week they can be planned for. */
2009
+ capacity: number;
2010
+ }
2011
+
2012
+ const PEOPLE: readonly Person[] = [
2013
+ { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
2014
+ { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
2015
+ { id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
2016
+ { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
2017
+ { id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
2018
+ { id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
2019
+ { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
2020
+ { id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
2021
+ { id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
2022
+ { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
2023
+ ];
2024
+
1334
2025
  const METRICS: Metric[] = [
1335
2026
  { id: "heads", label: "Headcount", unit: "HC" },
1336
2027
  { id: "fte", label: "Full-time equivalents", unit: "FTE", decimals: 1 },
@@ -1354,8 +2045,6 @@ export default function TwoTeams() {
1354
2045
  }
1355
2046
  ```
1356
2047
 
1357
- It imports `planning.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1358
-
1359
2048
  ##### A number one metric does not have
1360
2049
 
1361
2050
  `null` for one metric makes absent only what depends on it in that metric, with the reason; the other metric sums on.
@@ -1391,22 +2080,82 @@ export default function NotRecorded() {
1391
2080
  A tree of sums, built from data: every cost centre folds to its total and opens into its teams, each metric summed on its own.
1392
2081
 
1393
2082
  ```tsx
1394
- import { COST_CENTRES, TEAMS } from "./controlling";
1395
2083
  import { Calculation, Given, Sum } from "@umriss-ui/calculation";
1396
2084
  import type { Metric } from "@umriss-ui/calculation";
1397
2085
 
1398
- const METRICS: Metric[] = [
1399
- { id: "heads", label: "Headcount", unit: "HC" },
1400
- { id: "fte", label: "Full-time equivalents", unit: "FTE", decimals: 1 },
1401
- ];
2086
+ /* Data from the controlling world, written out here so the example runs on its own. */
1402
2087
 
1403
- const COMMERCIAL = ["CC-1100", "CC-1200", "CC-3100"];
2088
+ interface CostCentre {
2089
+ id: string;
2090
+ name: string;
2091
+ owner: string;
2092
+ /** The budget of one month. */
2093
+ monthlyBudget: number;
2094
+ }
1404
2095
 
1405
- export default function BusinessLine() {
1406
- return (
1407
- <Calculation aria-label="Staff of the commercial business line, 17 March" metrics={METRICS}>
1408
- <Sum label="Commercial">
1409
- {COMMERCIAL.map((id) => (
2096
+ const COST_CENTRES: readonly CostCentre[] = [
2097
+ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
2098
+ { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
2099
+ { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
2100
+ { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
2101
+ { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
2102
+ { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
2103
+ { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
2104
+ { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
2105
+ { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
2106
+ ];
2107
+
2108
+ interface Team {
2109
+ /** The cost centre the team books to. */
2110
+ costCentre: string;
2111
+ name: string;
2112
+ /** People employed, whatever their hours. */
2113
+ headcount: number;
2114
+ /** Full-time equivalents: contracted hours over 40 a week. */
2115
+ fte: number;
2116
+ /** Staff cost of a month, in thousand euros. */
2117
+ monthlyCost: number;
2118
+ }
2119
+
2120
+ /** Every team, on 17 March: 102 people, 91.5 full-time equivalents. */
2121
+ const TEAMS: readonly Team[] = [
2122
+ { costCentre: "CC-1100", name: "Field sales North", headcount: 9, fte: 8.6, monthlyCost: 61.2 },
2123
+ { costCentre: "CC-1100", name: "Field sales South", headcount: 8, fte: 7.5, monthlyCost: 53.9 },
2124
+ { costCentre: "CC-1100", name: "Inside sales", headcount: 6, fte: 5.2, monthlyCost: 29.8 },
2125
+ { costCentre: "CC-1100", name: "Key accounts", headcount: 4, fte: 4.0, monthlyCost: 33.6 },
2126
+ { costCentre: "CC-1200", name: "Brand", headcount: 3, fte: 2.8, monthlyCost: 19.4 },
2127
+ { costCentre: "CC-1200", name: "Online marketing", headcount: 4, fte: 3.5, monthlyCost: 24.1 },
2128
+ { costCentre: "CC-1200", name: "Trade fairs", headcount: 2, fte: 1.5, monthlyCost: 10.2 },
2129
+ { costCentre: "CC-3100", name: "Service desk", headcount: 7, fte: 5.9, monthlyCost: 31.3 },
2130
+ { costCentre: "CC-3100", name: "Returns", headcount: 3, fte: 2.5, monthlyCost: 12.6 },
2131
+ { costCentre: "CC-2100", name: "Seating", headcount: 6, fte: 5.8, monthlyCost: 43.5 },
2132
+ { costCentre: "CC-2100", name: "Desks", headcount: 7, fte: 6.5, monthlyCost: 48.1 },
2133
+ { costCentre: "CC-2100", name: "Storage", headcount: 5, fte: 4.6, monthlyCost: 33.9 },
2134
+ { costCentre: "CC-2100", name: "Test lab", headcount: 3, fte: 2.5, monthlyCost: 16.8 },
2135
+ { costCentre: "CC-2200", name: "Industrial design", headcount: 4, fte: 3.6, monthlyCost: 27.2 },
2136
+ { costCentre: "CC-2200", name: "Colour and material", headcount: 2, fte: 1.8, monthlyCost: 12.9 },
2137
+ { costCentre: "CC-4100", name: "Accounting", headcount: 5, fte: 4.4, monthlyCost: 27.5 },
2138
+ { costCentre: "CC-4100", name: "Controlling", headcount: 3, fte: 3.0, monthlyCost: 21.3 },
2139
+ { costCentre: "CC-4200", name: "Recruiting", headcount: 2, fte: 1.8, monthlyCost: 12.4 },
2140
+ { costCentre: "CC-4200", name: "Payroll", headcount: 2, fte: 1.5, monthlyCost: 9.6 },
2141
+ { costCentre: "CC-4300", name: "Workplace", headcount: 4, fte: 3.8, monthlyCost: 24.2 },
2142
+ { costCentre: "CC-4300", name: "Applications", headcount: 5, fte: 4.5, monthlyCost: 34.8 },
2143
+ { costCentre: "CC-4400", name: "Buildings", headcount: 3, fte: 3.0, monthlyCost: 16.9 },
2144
+ { costCentre: "CC-4400", name: "Canteen", headcount: 5, fte: 3.2, monthlyCost: 14.7 },
2145
+ ];
2146
+
2147
+ const METRICS: Metric[] = [
2148
+ { id: "heads", label: "Headcount", unit: "HC" },
2149
+ { id: "fte", label: "Full-time equivalents", unit: "FTE", decimals: 1 },
2150
+ ];
2151
+
2152
+ const COMMERCIAL = ["CC-1100", "CC-1200", "CC-3100"];
2153
+
2154
+ export default function BusinessLine() {
2155
+ return (
2156
+ <Calculation aria-label="Staff of the commercial business line, 17 March" metrics={METRICS}>
2157
+ <Sum label="Commercial">
2158
+ {COMMERCIAL.map((id) => (
1410
2159
  <Sum key={id} label={COST_CENTRES.find((centre) => centre.id === id)!.name}>
1411
2160
  {TEAMS.filter((team) => team.costCentre === id).map((team) => (
1412
2161
  <Given key={team.name} label={team.name} value={{ heads: team.headcount, fte: team.fte }} />
@@ -1419,8 +2168,6 @@ export default function BusinessLine() {
1419
2168
  }
1420
2169
  ```
1421
2170
 
1422
- It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1423
-
1424
2171
  ##### Staff movement as a chain
1425
2172
 
1426
2173
  The chain works per metric too: joiners added, leavers taken away, and every interim closes with its units.
@@ -1456,10 +2203,70 @@ export default function StaffMovement() {
1456
2203
  Any number of metrics, each with its unit and places. Narrow, where the figures leave the label too little room, each line puts them beneath its label.
1457
2204
 
1458
2205
  ```tsx
1459
- import { COST_CENTRES, TEAMS } from "./controlling";
1460
2206
  import { Calculation, Given, Sum } from "@umriss-ui/calculation";
1461
2207
  import type { Metric } from "@umriss-ui/calculation";
1462
2208
 
2209
+ /* Data from the controlling world, written out here so the example runs on its own. */
2210
+
2211
+ interface CostCentre {
2212
+ id: string;
2213
+ name: string;
2214
+ owner: string;
2215
+ /** The budget of one month. */
2216
+ monthlyBudget: number;
2217
+ }
2218
+
2219
+ const COST_CENTRES: readonly CostCentre[] = [
2220
+ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
2221
+ { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
2222
+ { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
2223
+ { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
2224
+ { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
2225
+ { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
2226
+ { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
2227
+ { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
2228
+ { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
2229
+ ];
2230
+
2231
+ interface Team {
2232
+ /** The cost centre the team books to. */
2233
+ costCentre: string;
2234
+ name: string;
2235
+ /** People employed, whatever their hours. */
2236
+ headcount: number;
2237
+ /** Full-time equivalents: contracted hours over 40 a week. */
2238
+ fte: number;
2239
+ /** Staff cost of a month, in thousand euros. */
2240
+ monthlyCost: number;
2241
+ }
2242
+
2243
+ /** Every team, on 17 March: 102 people, 91.5 full-time equivalents. */
2244
+ const TEAMS: readonly Team[] = [
2245
+ { costCentre: "CC-1100", name: "Field sales North", headcount: 9, fte: 8.6, monthlyCost: 61.2 },
2246
+ { costCentre: "CC-1100", name: "Field sales South", headcount: 8, fte: 7.5, monthlyCost: 53.9 },
2247
+ { costCentre: "CC-1100", name: "Inside sales", headcount: 6, fte: 5.2, monthlyCost: 29.8 },
2248
+ { costCentre: "CC-1100", name: "Key accounts", headcount: 4, fte: 4.0, monthlyCost: 33.6 },
2249
+ { costCentre: "CC-1200", name: "Brand", headcount: 3, fte: 2.8, monthlyCost: 19.4 },
2250
+ { costCentre: "CC-1200", name: "Online marketing", headcount: 4, fte: 3.5, monthlyCost: 24.1 },
2251
+ { costCentre: "CC-1200", name: "Trade fairs", headcount: 2, fte: 1.5, monthlyCost: 10.2 },
2252
+ { costCentre: "CC-3100", name: "Service desk", headcount: 7, fte: 5.9, monthlyCost: 31.3 },
2253
+ { costCentre: "CC-3100", name: "Returns", headcount: 3, fte: 2.5, monthlyCost: 12.6 },
2254
+ { costCentre: "CC-2100", name: "Seating", headcount: 6, fte: 5.8, monthlyCost: 43.5 },
2255
+ { costCentre: "CC-2100", name: "Desks", headcount: 7, fte: 6.5, monthlyCost: 48.1 },
2256
+ { costCentre: "CC-2100", name: "Storage", headcount: 5, fte: 4.6, monthlyCost: 33.9 },
2257
+ { costCentre: "CC-2100", name: "Test lab", headcount: 3, fte: 2.5, monthlyCost: 16.8 },
2258
+ { costCentre: "CC-2200", name: "Industrial design", headcount: 4, fte: 3.6, monthlyCost: 27.2 },
2259
+ { costCentre: "CC-2200", name: "Colour and material", headcount: 2, fte: 1.8, monthlyCost: 12.9 },
2260
+ { costCentre: "CC-4100", name: "Accounting", headcount: 5, fte: 4.4, monthlyCost: 27.5 },
2261
+ { costCentre: "CC-4100", name: "Controlling", headcount: 3, fte: 3.0, monthlyCost: 21.3 },
2262
+ { costCentre: "CC-4200", name: "Recruiting", headcount: 2, fte: 1.8, monthlyCost: 12.4 },
2263
+ { costCentre: "CC-4200", name: "Payroll", headcount: 2, fte: 1.5, monthlyCost: 9.6 },
2264
+ { costCentre: "CC-4300", name: "Workplace", headcount: 4, fte: 3.8, monthlyCost: 24.2 },
2265
+ { costCentre: "CC-4300", name: "Applications", headcount: 5, fte: 4.5, monthlyCost: 34.8 },
2266
+ { costCentre: "CC-4400", name: "Buildings", headcount: 3, fte: 3.0, monthlyCost: 16.9 },
2267
+ { costCentre: "CC-4400", name: "Canteen", headcount: 5, fte: 3.2, monthlyCost: 14.7 },
2268
+ ];
2269
+
1463
2270
  const METRICS: Metric[] = [
1464
2271
  { id: "heads", label: "Headcount", unit: "HC" },
1465
2272
  { id: "fte", label: "Full-time equivalents", unit: "FTE", decimals: 1 },
@@ -1489,17 +2296,88 @@ export default function StaffCost() {
1489
2296
  }
1490
2297
  ```
1491
2298
 
1492
- It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1493
-
1494
2299
  ##### The whole company
1495
2300
 
1496
2301
  Three levels from data - business line, cost centre, team - and three metrics: 23 teams folded to three lines a reader opens one by one.
1497
2302
 
1498
2303
  ```tsx
1499
- import { BUSINESS_LINES, COST_CENTRES, TEAMS } from "./controlling";
1500
2304
  import { Calculation, Given, Sum } from "@umriss-ui/calculation";
1501
2305
  import type { Metric } from "@umriss-ui/calculation";
1502
2306
 
2307
+ /* Data from the controlling world, written out here so the example runs on its own. */
2308
+
2309
+ interface CostCentre {
2310
+ id: string;
2311
+ name: string;
2312
+ owner: string;
2313
+ /** The budget of one month. */
2314
+ monthlyBudget: number;
2315
+ }
2316
+
2317
+ const COST_CENTRES: readonly CostCentre[] = [
2318
+ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
2319
+ { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
2320
+ { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
2321
+ { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
2322
+ { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
2323
+ { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
2324
+ { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
2325
+ { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
2326
+ { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
2327
+ ];
2328
+
2329
+ interface Team {
2330
+ /** The cost centre the team books to. */
2331
+ costCentre: string;
2332
+ name: string;
2333
+ /** People employed, whatever their hours. */
2334
+ headcount: number;
2335
+ /** Full-time equivalents: contracted hours over 40 a week. */
2336
+ fte: number;
2337
+ /** Staff cost of a month, in thousand euros. */
2338
+ monthlyCost: number;
2339
+ }
2340
+
2341
+ /** Every team, on 17 March: 102 people, 91.5 full-time equivalents. */
2342
+ const TEAMS: readonly Team[] = [
2343
+ { costCentre: "CC-1100", name: "Field sales North", headcount: 9, fte: 8.6, monthlyCost: 61.2 },
2344
+ { costCentre: "CC-1100", name: "Field sales South", headcount: 8, fte: 7.5, monthlyCost: 53.9 },
2345
+ { costCentre: "CC-1100", name: "Inside sales", headcount: 6, fte: 5.2, monthlyCost: 29.8 },
2346
+ { costCentre: "CC-1100", name: "Key accounts", headcount: 4, fte: 4.0, monthlyCost: 33.6 },
2347
+ { costCentre: "CC-1200", name: "Brand", headcount: 3, fte: 2.8, monthlyCost: 19.4 },
2348
+ { costCentre: "CC-1200", name: "Online marketing", headcount: 4, fte: 3.5, monthlyCost: 24.1 },
2349
+ { costCentre: "CC-1200", name: "Trade fairs", headcount: 2, fte: 1.5, monthlyCost: 10.2 },
2350
+ { costCentre: "CC-3100", name: "Service desk", headcount: 7, fte: 5.9, monthlyCost: 31.3 },
2351
+ { costCentre: "CC-3100", name: "Returns", headcount: 3, fte: 2.5, monthlyCost: 12.6 },
2352
+ { costCentre: "CC-2100", name: "Seating", headcount: 6, fte: 5.8, monthlyCost: 43.5 },
2353
+ { costCentre: "CC-2100", name: "Desks", headcount: 7, fte: 6.5, monthlyCost: 48.1 },
2354
+ { costCentre: "CC-2100", name: "Storage", headcount: 5, fte: 4.6, monthlyCost: 33.9 },
2355
+ { costCentre: "CC-2100", name: "Test lab", headcount: 3, fte: 2.5, monthlyCost: 16.8 },
2356
+ { costCentre: "CC-2200", name: "Industrial design", headcount: 4, fte: 3.6, monthlyCost: 27.2 },
2357
+ { costCentre: "CC-2200", name: "Colour and material", headcount: 2, fte: 1.8, monthlyCost: 12.9 },
2358
+ { costCentre: "CC-4100", name: "Accounting", headcount: 5, fte: 4.4, monthlyCost: 27.5 },
2359
+ { costCentre: "CC-4100", name: "Controlling", headcount: 3, fte: 3.0, monthlyCost: 21.3 },
2360
+ { costCentre: "CC-4200", name: "Recruiting", headcount: 2, fte: 1.8, monthlyCost: 12.4 },
2361
+ { costCentre: "CC-4200", name: "Payroll", headcount: 2, fte: 1.5, monthlyCost: 9.6 },
2362
+ { costCentre: "CC-4300", name: "Workplace", headcount: 4, fte: 3.8, monthlyCost: 24.2 },
2363
+ { costCentre: "CC-4300", name: "Applications", headcount: 5, fte: 4.5, monthlyCost: 34.8 },
2364
+ { costCentre: "CC-4400", name: "Buildings", headcount: 3, fte: 3.0, monthlyCost: 16.9 },
2365
+ { costCentre: "CC-4400", name: "Canteen", headcount: 5, fte: 3.2, monthlyCost: 14.7 },
2366
+ ];
2367
+
2368
+ interface BusinessLine {
2369
+ name: string;
2370
+ /** Its cost centres, in the order a report lists them. */
2371
+ costCentres: readonly string[];
2372
+ }
2373
+
2374
+ /** How the company reports: three business lines over the nine cost centres. */
2375
+ const BUSINESS_LINES: readonly BusinessLine[] = [
2376
+ { name: "Commercial", costCentres: ["CC-1100", "CC-1200", "CC-3100"] },
2377
+ { name: "Product", costCentres: ["CC-2100", "CC-2200"] },
2378
+ { name: "Corporate services", costCentres: ["CC-4100", "CC-4200", "CC-4300", "CC-4400"] },
2379
+ ];
2380
+
1503
2381
  const METRICS: Metric[] = [
1504
2382
  { id: "heads", label: "Headcount", unit: "HC" },
1505
2383
  { id: "fte", label: "Full-time equivalents", unit: "FTE", decimals: 1 },
@@ -1533,8 +2411,6 @@ export default function WholeCompany() {
1533
2411
  }
1534
2412
  ```
1535
2413
 
1536
- It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1537
-
1538
2414
  #### When to use something else
1539
2415
 
1540
2416
  - A ratio across two metrics, such as FTE per head → a calculation of its own, a `Quotient` of two givens
@@ -1708,73 +2584,209 @@ Demo page: https://romanhaendler.github.io/umriss-ui/calculation/worked-examples
1708
2584
  Eight tours from data, some sixty quantities: the sheet stands open, every tree in it folded, and the stale diesel price says so.
1709
2585
 
1710
2586
  ```tsx
1711
- import { COST_RATES, TOURS, VEHICLES } from "./logistics";
1712
2587
  import { Calculation, Chain, DividedBy, Given, Interim, Plus, Product, Sum } from "@umriss-ui/calculation";
1713
2588
 
1714
- const typeOf = (vehicleId: string) => VEHICLES.find((vehicle) => vehicle.id === vehicleId)!.type;
1715
- const hours = (tour: (typeof TOURS)[number]) => (tour.to - tour.from) / 3_600_000 + tour.loading / 60;
1716
- const DIESEL = TOURS.filter((tour) => typeOf(tour.vehicle) !== "e-van");
1717
- const ELECTRIC = TOURS.filter((tour) => typeOf(tour.vehicle) === "e-van");
1718
- const STOPS = TOURS.reduce((sum, tour) => sum + tour.stops.length, 0);
2589
+ /* Data from the logistics world, written out here so the example runs on its own. */
1719
2590
 
1720
- const AGES = { stale: 24 * 3_600_000, lost: 14 * 24 * 3_600_000 };
2591
+ /** A small LCG - the same numbers on every computer. */
2592
+ function random(seed: number): () => number {
2593
+ let s = seed >>> 0;
2594
+ return () => {
2595
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
2596
+ return s / 4294967296;
2597
+ };
2598
+ }
1721
2599
 
1722
- export default function CostPerTour() {
1723
- return (
1724
- <Calculation aria-label="Cost per stop, all tours, Tuesday">
1725
- <Chain>
1726
- <Product label="Drivers" unit="€" decimals={2}>
1727
- <Sum label="Driver hours" unit="h" decimals={1}>
1728
- {TOURS.map((tour) => (
1729
- <Given key={tour.id} label={`Hours, ${tour.id}`} value={hours(tour)} unit="h" decimals={1} />
1730
- ))}
1731
- </Sum>
1732
- <Given label="Driver rate, all in" value={COST_RATES.driverPerHour} unit="€/h" decimals={2} />
1733
- </Product>
1734
- <Plus>
1735
- <Product label="Diesel" unit="€" decimals={2}>
1736
- <Sum label="Diesel used" unit="l" decimals={1}>
1737
- {DIESEL.map((tour) => (
1738
- <Product key={tour.id} label={`Diesel, ${tour.id}`} unit="l" decimals={1}>
1739
- <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
1740
- <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km[typeOf(tour.vehicle)] / 100} unit="l/km" />
1741
- </Product>
1742
- ))}
1743
- </Sum>
1744
- <Given
1745
- label="Diesel price"
1746
- value={COST_RATES.dieselPerLitre}
1747
- unit="€/l"
1748
- decimals={2}
1749
- source="Fuel card, weekly price"
1750
- asOf={new Date(2026, 2, 14, 6, 0)}
1751
- ages={AGES}
1752
- />
1753
- </Product>
1754
- </Plus>
1755
- <Plus>
1756
- <Product label="Electricity" unit="€" decimals={2}>
1757
- <Sum label="Electricity used" unit="kWh" decimals={1}>
1758
- {ELECTRIC.map((tour) => (
1759
- <Product key={tour.id} label={`Electricity, ${tour.id}`} unit="kWh" decimals={1}>
1760
- <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
1761
- <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km["e-van"] / 100} unit="kWh/km" />
1762
- </Product>
1763
- ))}
1764
- </Sum>
1765
- <Given label="Electricity price" value={COST_RATES.electricityPerKwh} unit="€/kWh" decimals={2} source="Depot tariff 2026" />
1766
- </Product>
1767
- </Plus>
1768
- <Plus>
1769
- <Sum label="Vehicle days" unit="€" decimals={2}>
1770
- {TOURS.map((tour) => (
1771
- <Given key={tour.id} label={`Vehicle day, ${tour.id}`} value={COST_RATES.vehiclePerDay[typeOf(tour.vehicle)]} unit="€" decimals={2} />
1772
- ))}
1773
- </Sum>
1774
- </Plus>
1775
- <Interim label="Cost of the day's tours" unit="€" decimals={2} />
1776
- <DividedBy label="Stops" value={STOPS} unit="stops" />
1777
- <Interim
2600
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2601
+ const MINUTE = 60_000;
2602
+
2603
+ interface Vehicle {
2604
+ id: string;
2605
+ plate: string;
2606
+ type: "van" | "e-van" | "truck";
2607
+ /** Payload, in kg. */
2608
+ capacity: number;
2609
+ depot: string;
2610
+ }
2611
+
2612
+ const VEHICLES: readonly Vehicle[] = [
2613
+ { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
2614
+ { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
2615
+ { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
2616
+ { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
2617
+ { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
2618
+ { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
2619
+ { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
2620
+ { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
2621
+ ];
2622
+
2623
+ const DRIVERS = [
2624
+ { id: "d1", name: "Martin Hale", depot: "north" },
2625
+ { id: "d2", name: "Nadia Petrova", depot: "north" },
2626
+ { id: "d3", name: "Owen Carter", depot: "north" },
2627
+ { id: "d4", name: "Lucia Romero", depot: "river" },
2628
+ { id: "d5", name: "Ben Adeyemi", depot: "river" },
2629
+ { id: "d6", name: "Hanna Berg", depot: "east" },
2630
+ { id: "d7", name: "Yusuf Demir", depot: "east" },
2631
+ { id: "d8", name: "Clara Wendt", depot: "east" },
2632
+ ] as const;
2633
+
2634
+ const CUSTOMERS = [
2635
+ "Holloway Garden Supplies",
2636
+ "Marlow & Finch Books",
2637
+ "Oakridge Pharmacy",
2638
+ "Brixley Cycles",
2639
+ "Tamsin's Bakery",
2640
+ "Northfold Office",
2641
+ "Pellham Hardware",
2642
+ "Greywick Studio",
2643
+ "Juniper Lane Florist",
2644
+ "Ashcombe Dental",
2645
+ ] as const;
2646
+
2647
+ interface Stop {
2648
+ id: string;
2649
+ shipment: string;
2650
+ customer: string;
2651
+ /** The window the customer was promised, `[from, to]`. */
2652
+ window: readonly [number, number];
2653
+ /** When the tour plans to be there. */
2654
+ arrival: number;
2655
+ }
2656
+
2657
+ interface Tour {
2658
+ id: string;
2659
+ vehicle: string;
2660
+ driver: string;
2661
+ depot: string;
2662
+ /** Leaves the depot, loaded; back at the depot. */
2663
+ from: number;
2664
+ to: number;
2665
+ /** Minutes of loading before `from`. */
2666
+ loading: number;
2667
+ km: number;
2668
+ stops: readonly Stop[];
2669
+ }
2670
+
2671
+ /** One tour per vehicle. Stops every half hour or so, each with a two-hour
2672
+ window; every seventh arrival misses its window - the late ones a
2673
+ dispatcher looks for. */
2674
+ function tour(index: number): Tour {
2675
+ const r = random(700 + index);
2676
+ const vehicle = VEHICLES[index]!;
2677
+ const truck = vehicle.type === "truck";
2678
+ const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
2679
+ const count = truck ? 5 : 8 + Math.floor(r() * 4);
2680
+ const stops: Stop[] = [];
2681
+ let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
2682
+ for (let i = 0; i < count; i++) {
2683
+ const n = index * 20 + i;
2684
+ const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
2685
+ /* A late stop's window closed half an hour or more before the arrival. */
2686
+ const late = i >= 2 && n % 7 === 3;
2687
+ const halfHour = 30 * MINUTE;
2688
+ const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
2689
+ stops.push({
2690
+ id: `s-${index + 1}-${i + 1}`,
2691
+ shipment: `FP-${(1_004_210 + n * 13).toString()}`,
2692
+ customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
2693
+ window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
2694
+ arrival,
2695
+ });
2696
+ arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
2697
+ }
2698
+ return {
2699
+ id: `T-${String(index + 1).padStart(2, "0")}`,
2700
+ vehicle: vehicle.id,
2701
+ driver: DRIVERS[index]!.id,
2702
+ depot: vehicle.depot,
2703
+ from,
2704
+ to: arrival + 30 * MINUTE,
2705
+ loading: truck ? 45 : 30,
2706
+ km: Math.round((truck ? 140 : 70) + r() * 40),
2707
+ stops,
2708
+ };
2709
+ }
2710
+
2711
+ const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
2712
+
2713
+ /** What a tour costs, per unit - the inputs of a cost per tour. */
2714
+ const COST_RATES = {
2715
+ /** Diesel, per litre; electricity, per kWh. */
2716
+ dieselPerLitre: 1.68,
2717
+ electricityPerKwh: 0.31,
2718
+ /** Consumption per 100 km. */
2719
+ per100Km: { van: 9.5, truck: 24, "e-van": 22 },
2720
+ /** A driver's hour, all in. */
2721
+ driverPerHour: 34.5,
2722
+ /** Lease, insurance and upkeep, per day. */
2723
+ vehiclePerDay: { van: 58, truck: 145, "e-van": 72 },
2724
+ } as const;
2725
+
2726
+ const typeOf = (vehicleId: string) => VEHICLES.find((vehicle) => vehicle.id === vehicleId)!.type;
2727
+ const hours = (tour: (typeof TOURS)[number]) => (tour.to - tour.from) / 3_600_000 + tour.loading / 60;
2728
+ const DIESEL = TOURS.filter((tour) => typeOf(tour.vehicle) !== "e-van");
2729
+ const ELECTRIC = TOURS.filter((tour) => typeOf(tour.vehicle) === "e-van");
2730
+ const STOPS = TOURS.reduce((sum, tour) => sum + tour.stops.length, 0);
2731
+
2732
+ const AGES = { stale: 24 * 3_600_000, lost: 14 * 24 * 3_600_000 };
2733
+
2734
+ export default function CostPerTour() {
2735
+ return (
2736
+ <Calculation aria-label="Cost per stop, all tours, Tuesday">
2737
+ <Chain>
2738
+ <Product label="Drivers" unit="€" decimals={2}>
2739
+ <Sum label="Driver hours" unit="h" decimals={1}>
2740
+ {TOURS.map((tour) => (
2741
+ <Given key={tour.id} label={`Hours, ${tour.id}`} value={hours(tour)} unit="h" decimals={1} />
2742
+ ))}
2743
+ </Sum>
2744
+ <Given label="Driver rate, all in" value={COST_RATES.driverPerHour} unit="€/h" decimals={2} />
2745
+ </Product>
2746
+ <Plus>
2747
+ <Product label="Diesel" unit="€" decimals={2}>
2748
+ <Sum label="Diesel used" unit="l" decimals={1}>
2749
+ {DIESEL.map((tour) => (
2750
+ <Product key={tour.id} label={`Diesel, ${tour.id}`} unit="l" decimals={1}>
2751
+ <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
2752
+ <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km[typeOf(tour.vehicle)] / 100} unit="l/km" />
2753
+ </Product>
2754
+ ))}
2755
+ </Sum>
2756
+ <Given
2757
+ label="Diesel price"
2758
+ value={COST_RATES.dieselPerLitre}
2759
+ unit="€/l"
2760
+ decimals={2}
2761
+ source="Fuel card, weekly price"
2762
+ asOf={new Date(2026, 2, 14, 6, 0)}
2763
+ ages={AGES}
2764
+ />
2765
+ </Product>
2766
+ </Plus>
2767
+ <Plus>
2768
+ <Product label="Electricity" unit="€" decimals={2}>
2769
+ <Sum label="Electricity used" unit="kWh" decimals={1}>
2770
+ {ELECTRIC.map((tour) => (
2771
+ <Product key={tour.id} label={`Electricity, ${tour.id}`} unit="kWh" decimals={1}>
2772
+ <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
2773
+ <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km["e-van"] / 100} unit="kWh/km" />
2774
+ </Product>
2775
+ ))}
2776
+ </Sum>
2777
+ <Given label="Electricity price" value={COST_RATES.electricityPerKwh} unit="€/kWh" decimals={2} source="Depot tariff 2026" />
2778
+ </Product>
2779
+ </Plus>
2780
+ <Plus>
2781
+ <Sum label="Vehicle days" unit="€" decimals={2}>
2782
+ {TOURS.map((tour) => (
2783
+ <Given key={tour.id} label={`Vehicle day, ${tour.id}`} value={COST_RATES.vehiclePerDay[typeOf(tour.vehicle)]} unit="€" decimals={2} />
2784
+ ))}
2785
+ </Sum>
2786
+ </Plus>
2787
+ <Interim label="Cost of the day's tours" unit="€" decimals={2} />
2788
+ <DividedBy label="Stops" value={STOPS} unit="stops" />
2789
+ <Interim
1778
2790
  label="Cost per stop"
1779
2791
  unit="€/stop"
1780
2792
  decimals={2}
@@ -1786,18 +2798,78 @@ export default function CostPerTour() {
1786
2798
  }
1787
2799
  ```
1788
2800
 
1789
- It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1790
-
1791
2801
  ##### Invoices with discount and VAT
1792
2802
 
1793
2803
  Each invoice is a chain from its lines, discounts and VAT by rate; as operands of one sum they fold to their totals.
1794
2804
 
1795
2805
  ```tsx
1796
2806
  import type { ReactElement } from "react";
1797
- import { INVOICES } from "./controlling";
1798
- import type { Invoice } from "./controlling";
1799
2807
  import { Calculation, Chain, Difference, Given, Interim, Plus, Product, Ref, Sum } from "@umriss-ui/calculation";
1800
2808
 
2809
+ /* Data from the controlling world, written out here so the example runs on its own. */
2810
+
2811
+ const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();
2812
+
2813
+ interface InvoiceLine {
2814
+ description: string;
2815
+ quantity: number;
2816
+ unitPrice: number;
2817
+ /** A fraction: 0.1 is ten per cent off. */
2818
+ discount: number;
2819
+ /** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
2820
+ vatRate: number;
2821
+ }
2822
+
2823
+ interface Invoice {
2824
+ id: string;
2825
+ supplier: string;
2826
+ costCentre: string;
2827
+ received: number;
2828
+ due: number;
2829
+ status: "awaiting approval" | "approved" | "paid" | "rejected";
2830
+ lines: readonly InvoiceLine[];
2831
+ }
2832
+
2833
+ const INVOICES: readonly Invoice[] = [
2834
+ {
2835
+ id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
2836
+ lines: [
2837
+ { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
2838
+ { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
2839
+ { description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
2840
+ ],
2841
+ },
2842
+ {
2843
+ id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
2844
+ lines: [
2845
+ { description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
2846
+ { description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
2847
+ ],
2848
+ },
2849
+ {
2850
+ id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
2851
+ lines: [
2852
+ { description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
2853
+ { description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
2854
+ ],
2855
+ },
2856
+ {
2857
+ id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
2858
+ lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
2859
+ },
2860
+ {
2861
+ id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
2862
+ lines: [
2863
+ { description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
2864
+ { description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
2865
+ ],
2866
+ },
2867
+ {
2868
+ id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
2869
+ lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
2870
+ },
2871
+ ];
2872
+
1801
2873
  const OPEN = INVOICES.filter((invoice) => invoice.status === "awaiting approval" || invoice.status === "approved");
1802
2874
  const percent = (rate: number) => `${Math.round(rate * 100)} %`;
1803
2875
 
@@ -1865,16 +2937,18 @@ export default function Invoices() {
1865
2937
  }
1866
2938
  ```
1867
2939
 
1868
- It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1869
-
1870
2940
  ##### OEE of a hall, weighted by planned time
1871
2941
 
1872
2942
  Three lines from data, three levels deep; line C's downtime is `null`, so its OEE and the hall's are missing while A and B stand.
1873
2943
 
1874
2944
  ```tsx
1875
- import { SHIFT_MINUTES } from "./plant";
1876
2945
  import { Calculation, Difference, Given, Product, Quotient, Ref, Sum } from "@umriss-ui/calculation";
1877
2946
 
2947
+ /* Data from the plant world, written out here so the example runs on its own. */
2948
+
2949
+ /** The early shift, 06:00 to 14:00. */
2950
+ const SHIFT_MINUTES = 480;
2951
+
1878
2952
  interface Line {
1879
2953
  name: string;
1880
2954
  planned: number;
@@ -1938,1587 +3012,3 @@ export default function HallOee() {
1938
3012
  );
1939
3013
  }
1940
3014
  ```
1941
-
1942
- It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1943
-
1944
- ## Files the examples show
1945
-
1946
- The demos' shared data, which some examples import from beside themselves. Copied with the example, it runs.
1947
-
1948
- ### `controlling.ts`
1949
-
1950
- ```ts
1951
- /**
1952
- * The controlling world: Carrow & Lisle, a mid-sized maker of office
1953
- * furniture, and its finance team's view of the year 2026.
1954
- *
1955
- * The standing cast:
1956
- * - `COST_CENTRES` - nine cost centres with their owner and monthly budget;
1957
- * - `MONTHS` and `LEDGER` - per cost centre and month: the budget, the actual
1958
- * where the month is closed (January and February), the forecast for every
1959
- * month; Marketing overspends, IT runs under;
1960
- * - `INVOICES` - incoming invoices with their lines (quantity, unit price,
1961
- * discount, VAT rate), and `APPROVALS`, who signed which, or has yet to;
1962
- * - `TEAMS` - the teams of each cost centre with their headcount, full-time
1963
- * equivalents and monthly staff cost, and `BUSINESS_LINES`, which cost
1964
- * centres each of the three business lines holds.
1965
- *
1966
- * Amounts in euros. "Now" is Tuesday, 17 March 2026. Plain data and small pure
1967
- * functions, no imports: copy the file beside an example and it runs.
1968
- */
1969
-
1970
- /** A small LCG - the same numbers on every computer. */
1971
- function random(seed: number): () => number {
1972
- let s = seed >>> 0;
1973
- return () => {
1974
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
1975
- return s / 4294967296;
1976
- };
1977
- }
1978
-
1979
- const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();
1980
-
1981
- export interface CostCentre {
1982
- id: string;
1983
- name: string;
1984
- owner: string;
1985
- /** The budget of one month. */
1986
- monthlyBudget: number;
1987
- }
1988
-
1989
- export const COST_CENTRES: readonly CostCentre[] = [
1990
- { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
1991
- { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
1992
- { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
1993
- { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
1994
- { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
1995
- { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
1996
- { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
1997
- { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
1998
- { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
1999
- ];
2000
-
2001
- /** The year's months, as `"2026-01"` … `"2026-12"`. */
2002
- export const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
2003
-
2004
- /** The months whose books are closed: they have an actual. */
2005
- export const CLOSED_MONTHS = 2;
2006
-
2007
- export interface LedgerRow {
2008
- costCentre: string;
2009
- month: string;
2010
- budget: number;
2011
- /** `null` while the month is open. */
2012
- actual: number | null;
2013
- forecast: number;
2014
- }
2015
-
2016
- /* Where a cost centre lands against its budget, as a factor: Marketing's
2017
- spring campaign overspends, IT's delayed licences underspend. */
2018
- const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
2019
-
2020
- /** Budget, actual and forecast of every cost centre in every month. */
2021
- export const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
2022
- const r = random(310 + c);
2023
- const tendency = TENDENCY[centre.id] ?? 1;
2024
- return MONTHS.map((month, m) => {
2025
- /* December pays the bonuses, August is the quiet month. */
2026
- const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
2027
- const budget = Math.round(centre.monthlyBudget * season);
2028
- const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
2029
- const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
2030
- return { costCentre: centre.id, month, budget, actual, forecast };
2031
- });
2032
- });
2033
-
2034
- export interface InvoiceLine {
2035
- description: string;
2036
- quantity: number;
2037
- unitPrice: number;
2038
- /** A fraction: 0.1 is ten per cent off. */
2039
- discount: number;
2040
- /** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
2041
- vatRate: number;
2042
- }
2043
-
2044
- export interface Invoice {
2045
- id: string;
2046
- supplier: string;
2047
- costCentre: string;
2048
- received: number;
2049
- due: number;
2050
- status: "awaiting approval" | "approved" | "paid" | "rejected";
2051
- lines: readonly InvoiceLine[];
2052
- }
2053
-
2054
- export const INVOICES: readonly Invoice[] = [
2055
- {
2056
- id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
2057
- lines: [
2058
- { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
2059
- { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
2060
- { description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
2061
- ],
2062
- },
2063
- {
2064
- id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
2065
- lines: [
2066
- { description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
2067
- { description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
2068
- ],
2069
- },
2070
- {
2071
- id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
2072
- lines: [
2073
- { description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
2074
- { description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
2075
- ],
2076
- },
2077
- {
2078
- id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
2079
- lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
2080
- },
2081
- {
2082
- id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
2083
- lines: [
2084
- { description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
2085
- { description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
2086
- ],
2087
- },
2088
- {
2089
- id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
2090
- lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
2091
- },
2092
- ];
2093
-
2094
- export interface Approval {
2095
- invoice: string;
2096
- step: "cost centre" | "finance";
2097
- approver: string;
2098
- decision: "approved" | "rejected" | "pending";
2099
- at?: number;
2100
- comment?: string;
2101
- }
2102
-
2103
- /** Two signatures an invoice needs: its cost centre's owner, then finance. */
2104
- export const APPROVALS: readonly Approval[] = [
2105
- { invoice: "INV-26-0318", step: "cost centre", approver: "Olga Ivanova", decision: "pending" },
2106
- { invoice: "INV-26-0317", step: "cost centre", approver: "Rafael Ortiz", decision: "approved", at: on(3, 16) },
2107
- { invoice: "INV-26-0317", step: "finance", approver: "Martina Vogel", decision: "pending" },
2108
- { invoice: "INV-26-0309", step: "cost centre", approver: "Kenji Arai", decision: "approved", at: on(3, 10) },
2109
- { invoice: "INV-26-0309", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 11) },
2110
- { invoice: "INV-26-0302", step: "cost centre", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
2111
- { invoice: "INV-26-0302", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
2112
- { invoice: "INV-26-0226", step: "cost centre", approver: "Helen Marsh", decision: "approved", at: on(2, 27) },
2113
- { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
2114
- { invoice: "INV-26-0221", step: "cost centre", approver: "Anika Sørensen", decision: "rejected", at: on(2, 24), comment: "Not ordered - the review was cancelled in January." },
2115
- ];
2116
-
2117
- export interface Team {
2118
- /** The cost centre the team books to. */
2119
- costCentre: string;
2120
- name: string;
2121
- /** People employed, whatever their hours. */
2122
- headcount: number;
2123
- /** Full-time equivalents: contracted hours over 40 a week. */
2124
- fte: number;
2125
- /** Staff cost of a month, in thousand euros. */
2126
- monthlyCost: number;
2127
- }
2128
-
2129
- /** Every team, on 17 March: 102 people, 91.5 full-time equivalents. */
2130
- export const TEAMS: readonly Team[] = [
2131
- { costCentre: "CC-1100", name: "Field sales North", headcount: 9, fte: 8.6, monthlyCost: 61.2 },
2132
- { costCentre: "CC-1100", name: "Field sales South", headcount: 8, fte: 7.5, monthlyCost: 53.9 },
2133
- { costCentre: "CC-1100", name: "Inside sales", headcount: 6, fte: 5.2, monthlyCost: 29.8 },
2134
- { costCentre: "CC-1100", name: "Key accounts", headcount: 4, fte: 4.0, monthlyCost: 33.6 },
2135
- { costCentre: "CC-1200", name: "Brand", headcount: 3, fte: 2.8, monthlyCost: 19.4 },
2136
- { costCentre: "CC-1200", name: "Online marketing", headcount: 4, fte: 3.5, monthlyCost: 24.1 },
2137
- { costCentre: "CC-1200", name: "Trade fairs", headcount: 2, fte: 1.5, monthlyCost: 10.2 },
2138
- { costCentre: "CC-3100", name: "Service desk", headcount: 7, fte: 5.9, monthlyCost: 31.3 },
2139
- { costCentre: "CC-3100", name: "Returns", headcount: 3, fte: 2.5, monthlyCost: 12.6 },
2140
- { costCentre: "CC-2100", name: "Seating", headcount: 6, fte: 5.8, monthlyCost: 43.5 },
2141
- { costCentre: "CC-2100", name: "Desks", headcount: 7, fte: 6.5, monthlyCost: 48.1 },
2142
- { costCentre: "CC-2100", name: "Storage", headcount: 5, fte: 4.6, monthlyCost: 33.9 },
2143
- { costCentre: "CC-2100", name: "Test lab", headcount: 3, fte: 2.5, monthlyCost: 16.8 },
2144
- { costCentre: "CC-2200", name: "Industrial design", headcount: 4, fte: 3.6, monthlyCost: 27.2 },
2145
- { costCentre: "CC-2200", name: "Colour and material", headcount: 2, fte: 1.8, monthlyCost: 12.9 },
2146
- { costCentre: "CC-4100", name: "Accounting", headcount: 5, fte: 4.4, monthlyCost: 27.5 },
2147
- { costCentre: "CC-4100", name: "Controlling", headcount: 3, fte: 3.0, monthlyCost: 21.3 },
2148
- { costCentre: "CC-4200", name: "Recruiting", headcount: 2, fte: 1.8, monthlyCost: 12.4 },
2149
- { costCentre: "CC-4200", name: "Payroll", headcount: 2, fte: 1.5, monthlyCost: 9.6 },
2150
- { costCentre: "CC-4300", name: "Workplace", headcount: 4, fte: 3.8, monthlyCost: 24.2 },
2151
- { costCentre: "CC-4300", name: "Applications", headcount: 5, fte: 4.5, monthlyCost: 34.8 },
2152
- { costCentre: "CC-4400", name: "Buildings", headcount: 3, fte: 3.0, monthlyCost: 16.9 },
2153
- { costCentre: "CC-4400", name: "Canteen", headcount: 5, fte: 3.2, monthlyCost: 14.7 },
2154
- ];
2155
-
2156
- export interface BusinessLine {
2157
- name: string;
2158
- /** Its cost centres, in the order a report lists them. */
2159
- costCentres: readonly string[];
2160
- }
2161
-
2162
- /** How the company reports: three business lines over the nine cost centres. */
2163
- export const BUSINESS_LINES: readonly BusinessLine[] = [
2164
- { name: "Commercial", costCentres: ["CC-1100", "CC-1200", "CC-3100"] },
2165
- { name: "Product", costCentres: ["CC-2100", "CC-2200"] },
2166
- { name: "Corporate services", costCentres: ["CC-4100", "CC-4200", "CC-4300", "CC-4400"] },
2167
- ];
2168
- ```
2169
-
2170
- ### `logistics.ts`
2171
-
2172
- ```ts
2173
- /**
2174
- * The logistics world: Ferrow Parcel, a regional parcel carrier with three
2175
- * depots.
2176
- *
2177
- * The standing cast:
2178
- * - `DEPOTS`, `VEHICLES` (plate, type, payload, depot) and `DRIVERS`;
2179
- * - `TOURS` - today's eight delivery tours, each with its vehicle, driver,
2180
- * start, end, distance and stops; a stop has a delivery window and the
2181
- * planned arrival, and a few arrive outside their window;
2182
- * - `SHIPMENTS` - one per stop, with customer, weight, status and window;
2183
- * - `VEHICLE_STATES` and `vehicleDay(vehicle)` - what a vehicle did through
2184
- * the day, quarter hour by quarter hour, for a state band;
2185
- * - `COST_RATES` - what a kilometre, an hour and a vehicle day cost, the
2186
- * inputs of a cost per tour.
2187
- *
2188
- * "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
2189
- * functions, no imports: copy the file beside an example and it runs.
2190
- */
2191
-
2192
- /** A small LCG - the same numbers on every computer. */
2193
- function random(seed: number): () => number {
2194
- let s = seed >>> 0;
2195
- return () => {
2196
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
2197
- return s / 4294967296;
2198
- };
2199
- }
2200
-
2201
- const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2202
- const MINUTE = 60_000;
2203
-
2204
- /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
2205
- export const NOW = at(10, 30);
2206
-
2207
- export const DEPOTS = [
2208
- { id: "north", name: "North depot" },
2209
- { id: "river", name: "Riverside depot" },
2210
- { id: "east", name: "East Gate depot" },
2211
- ] as const;
2212
-
2213
- export interface Vehicle {
2214
- id: string;
2215
- plate: string;
2216
- type: "van" | "e-van" | "truck";
2217
- /** Payload, in kg. */
2218
- capacity: number;
2219
- depot: string;
2220
- }
2221
-
2222
- export const VEHICLES: readonly Vehicle[] = [
2223
- { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
2224
- { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
2225
- { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
2226
- { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
2227
- { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
2228
- { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
2229
- { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
2230
- { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
2231
- ];
2232
-
2233
- export const DRIVERS = [
2234
- { id: "d1", name: "Martin Hale", depot: "north" },
2235
- { id: "d2", name: "Nadia Petrova", depot: "north" },
2236
- { id: "d3", name: "Owen Carter", depot: "north" },
2237
- { id: "d4", name: "Lucia Romero", depot: "river" },
2238
- { id: "d5", name: "Ben Adeyemi", depot: "river" },
2239
- { id: "d6", name: "Hanna Berg", depot: "east" },
2240
- { id: "d7", name: "Yusuf Demir", depot: "east" },
2241
- { id: "d8", name: "Clara Wendt", depot: "east" },
2242
- ] as const;
2243
-
2244
- const CUSTOMERS = [
2245
- "Holloway Garden Supplies",
2246
- "Marlow & Finch Books",
2247
- "Oakridge Pharmacy",
2248
- "Brixley Cycles",
2249
- "Tamsin's Bakery",
2250
- "Northfold Office",
2251
- "Pellham Hardware",
2252
- "Greywick Studio",
2253
- "Juniper Lane Florist",
2254
- "Ashcombe Dental",
2255
- ] as const;
2256
-
2257
- export interface Stop {
2258
- id: string;
2259
- shipment: string;
2260
- customer: string;
2261
- /** The window the customer was promised, `[from, to]`. */
2262
- window: readonly [number, number];
2263
- /** When the tour plans to be there. */
2264
- arrival: number;
2265
- }
2266
-
2267
- export interface Tour {
2268
- id: string;
2269
- vehicle: string;
2270
- driver: string;
2271
- depot: string;
2272
- /** Leaves the depot, loaded; back at the depot. */
2273
- from: number;
2274
- to: number;
2275
- /** Minutes of loading before `from`. */
2276
- loading: number;
2277
- km: number;
2278
- stops: readonly Stop[];
2279
- }
2280
-
2281
- /** One tour per vehicle. Stops every half hour or so, each with a two-hour
2282
- window; every seventh arrival misses its window - the late ones a
2283
- dispatcher looks for. */
2284
- function tour(index: number): Tour {
2285
- const r = random(700 + index);
2286
- const vehicle = VEHICLES[index]!;
2287
- const truck = vehicle.type === "truck";
2288
- const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
2289
- const count = truck ? 5 : 8 + Math.floor(r() * 4);
2290
- const stops: Stop[] = [];
2291
- let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
2292
- for (let i = 0; i < count; i++) {
2293
- const n = index * 20 + i;
2294
- const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
2295
- /* A late stop's window closed half an hour or more before the arrival. */
2296
- const late = i >= 2 && n % 7 === 3;
2297
- const halfHour = 30 * MINUTE;
2298
- const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
2299
- stops.push({
2300
- id: `s-${index + 1}-${i + 1}`,
2301
- shipment: `FP-${(1_004_210 + n * 13).toString()}`,
2302
- customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
2303
- window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
2304
- arrival,
2305
- });
2306
- arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
2307
- }
2308
- return {
2309
- id: `T-${String(index + 1).padStart(2, "0")}`,
2310
- vehicle: vehicle.id,
2311
- driver: DRIVERS[index]!.id,
2312
- depot: vehicle.depot,
2313
- from,
2314
- to: arrival + 30 * MINUTE,
2315
- loading: truck ? 45 : 30,
2316
- km: Math.round((truck ? 140 : 70) + r() * 40),
2317
- stops,
2318
- };
2319
- }
2320
-
2321
- export const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
2322
-
2323
- export interface Shipment {
2324
- id: string;
2325
- customer: string;
2326
- /** In kg. */
2327
- weight: number;
2328
- status: "delivered" | "out for delivery" | "failed attempt";
2329
- window: readonly [number, number];
2330
- tour: string;
2331
- }
2332
-
2333
- /** One shipment per stop. Before now, delivered - or a failed attempt, where
2334
- nobody was in; after now, out for delivery. */
2335
- export const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
2336
- one.stops.map((stop) => {
2337
- const n = Number(stop.shipment.slice(3));
2338
- return {
2339
- id: stop.shipment,
2340
- customer: stop.customer,
2341
- weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
2342
- status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
2343
- window: stop.window,
2344
- tour: one.id,
2345
- } satisfies Shipment;
2346
- }),
2347
- );
2348
-
2349
- /** The closed set of vehicle states. The order is the code. */
2350
- export const VEHICLE_STATES = [
2351
- { label: "Driving", color: "#2f6fb4" },
2352
- { label: "Loading", color: "#c08a2e" },
2353
- { label: "Idle", color: "#8a8f98" },
2354
- { label: "Break", color: "#6b8e5a" },
2355
- ] as const;
2356
-
2357
- export interface StatePoint {
2358
- t: number;
2359
- /** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
2360
- state: number | null;
2361
- }
2362
-
2363
- /** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
2364
- loading before its tour, driving between stops, idle at them now and then,
2365
- and a break around noon. */
2366
- export function vehicleDay(vehicleId: string): StatePoint[] {
2367
- const one = TOURS.find((t) => t.vehicle === vehicleId);
2368
- if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
2369
- const r = random(900 + TOURS.indexOf(one));
2370
- const points: StatePoint[] = [];
2371
- for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
2372
- let state: number | null = null;
2373
- if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
2374
- else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
2375
- points.push({ t, state });
2376
- }
2377
- return points;
2378
- }
2379
-
2380
- /** What a tour costs, per unit - the inputs of a cost per tour. */
2381
- export const COST_RATES = {
2382
- /** Diesel, per litre; electricity, per kWh. */
2383
- dieselPerLitre: 1.68,
2384
- electricityPerKwh: 0.31,
2385
- /** Consumption per 100 km. */
2386
- per100Km: { van: 9.5, truck: 24, "e-van": 22 },
2387
- /** A driver's hour, all in. */
2388
- driverPerHour: 34.5,
2389
- /** Lease, insurance and upkeep, per day. */
2390
- vehiclePerDay: { van: 58, truck: 145, "e-van": 72 },
2391
- } as const;
2392
-
2393
- /* ---------------------------------------------------------------------------
2394
- For @umriss-ui/charts: an e-van's battery, the North depot's week and
2395
- fortnight, the delays by cause and the parcels per depot and hour.
2396
- --------------------------------------------------------------------------- */
2397
-
2398
- const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
2399
- const HOUR = 60 * MINUTE;
2400
-
2401
- export interface BatteryPoint {
2402
- t: number;
2403
- /** State of charge, in per cent; `null` before the tour and after it. */
2404
- charge: number | null;
2405
- }
2406
-
2407
- /** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
2408
- full when the tour leaves, draining while it drives, a little while it
2409
- idles, and topped up at the depot's charger over the noon break. */
2410
- export function batteryDay(vehicleId: string): BatteryPoint[] {
2411
- let charge = 100;
2412
- return vehicleDay(vehicleId).map(({ t, state }) => {
2413
- if (state === null) return { t, charge: null };
2414
- const now = charge;
2415
- charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
2416
- return { t, charge: Math.round(now * 10) / 10 };
2417
- });
2418
- }
2419
-
2420
- /** The North depot's sorting hours: Monday to Friday, 06:00 to 22:00. */
2421
- export const SORTING_HOURS = Array.from({ length: 5 }, (_, day) => ({ from: on(3, 9 + day, 6), to: on(3, 9 + day, 22) }));
2422
-
2423
- export interface SortedPoint {
2424
- t: number;
2425
- /** Parcels sorted per hour. */
2426
- parcels: number;
2427
- }
2428
-
2429
- /** Last week's sorting at the North depot, quarter hour by quarter hour -
2430
- only in its sorting hours. */
2431
- export const SORTED: readonly SortedPoint[] = (() => {
2432
- const r = random(1963);
2433
- const points: SortedPoint[] = [];
2434
- let parcels = 420;
2435
- for (const { from } of SORTING_HOURS) {
2436
- for (let quarter = 0; quarter < 4 * 16; quarter++) {
2437
- parcels += (r() - 0.5) * 26;
2438
- points.push({ t: from + quarter * 15 * MINUTE, parcels: Math.round(parcels) });
2439
- }
2440
- }
2441
- return points;
2442
- })();
2443
-
2444
- export interface DepotDay {
2445
- /** Day of March. */
2446
- day: number;
2447
- /** Pallets that arrived, and that left on the tours. */
2448
- arrived: number;
2449
- dispatched: number;
2450
- /** Pallets on hand at the end of the day. */
2451
- onHand: number;
2452
- /** The range the stock is meant to stay in; `null` while it was reset
2453
- after the stocktake. */
2454
- targetLow: number | null;
2455
- targetHigh: number | null;
2456
- /** A pallet count by hand - it never quite agrees with the books. */
2457
- counted: number;
2458
- }
2459
-
2460
- /** The North depot's last fourteen days, 2 to 15 March. */
2461
- export const DEPOT_DAYS: readonly DepotDay[] = (() => {
2462
- const r = random(2026);
2463
- let onHand = 62;
2464
- return Array.from({ length: 14 }, (_, i) => {
2465
- const arrived = 30 + r() * 25;
2466
- const dispatched = 22 + r() * 20;
2467
- onHand += (arrived - dispatched) * 0.35;
2468
- const reset = i >= 6 && i <= 8;
2469
- return {
2470
- day: i + 2,
2471
- arrived: Math.round(arrived),
2472
- dispatched: Math.round(dispatched),
2473
- onHand: Math.round(onHand),
2474
- targetLow: reset ? null : Math.round(onHand - 9 - r() * 3),
2475
- targetHigh: reset ? null : Math.round(onHand + 9 + r() * 3),
2476
- counted: Math.round(onHand + (r() - 0.5) * 26),
2477
- };
2478
- });
2479
- })();
2480
-
2481
- export interface DayDelays {
2482
- /** The position on the x axis: the working day's index. */
2483
- day: number;
2484
- name: string;
2485
- /** Minutes late across all tours, by cause; `null` where it was not logged. */
2486
- traffic: number;
2487
- loading: number | null;
2488
- access: number;
2489
- }
2490
-
2491
- /** Two working weeks, 2 to 13 March. On the first Wednesday the loading log
2492
- was not kept - a gap, not a zero - and on the second Thursday a closed
2493
- bridge takes the day. */
2494
- export const DELAYS: readonly DayDelays[] = (() => {
2495
- const r = random(1204);
2496
- const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
2497
- return Array.from({ length: 10 }, (_, day) => ({
2498
- day,
2499
- name: `${names[day % 5]} ${2 + day + 2 * Math.floor(day / 5)}`,
2500
- traffic: Math.round((day === 8 ? 150 : 40) + r() * 30),
2501
- loading: day === 2 ? null : Math.round(10 + r() * 45),
2502
- access: Math.round(5 + r() * 25),
2503
- }));
2504
- })();
2505
-
2506
- export interface DepotHour {
2507
- t: number;
2508
- /** Parcels loaded per hour at each depot. */
2509
- north: number;
2510
- river: number;
2511
- east: number;
2512
- }
2513
-
2514
- /** Monday 06:00 to Tuesday 06:00, hour by hour. East Gate closes at night and
2515
- loads nothing - 0, not nothing. */
2516
- export const PARCELS_PER_HOUR: readonly DepotHour[] = (() => {
2517
- const r = random(906);
2518
- return Array.from({ length: 25 }, (_, i) => {
2519
- const hour = (6 + i) % 24;
2520
- const night = hour >= 22 || hour < 6;
2521
- return {
2522
- t: on(3, 16, 6) + i * HOUR,
2523
- north: Math.round(120 + r() * 30),
2524
- river: Math.round((night ? 60 : 90) + r() * 25),
2525
- east: night ? 0 : Math.round(70 + r() * 30),
2526
- };
2527
- });
2528
- })();
2529
- ```
2530
-
2531
- ### `operations.ts`
2532
-
2533
- ```ts
2534
- /**
2535
- * The operations world: Quillmere, a platform on which small businesses take
2536
- * online bookings, run as a set of services by six teams.
2537
- *
2538
- * The standing cast:
2539
- * - `SERVICES` - eight services with their team, tier, latency objective and
2540
- * availability target;
2541
- * - `ENGINEERS` - the people on call, and `ONCALL`, who is primary and
2542
- * secondary this week;
2543
- * - `metrics(service)` - latency and error rate every five minutes of today up
2544
- * to now; Checkout has a bad half hour from 09:40;
2545
- * - `INCIDENTS` - the last week's incidents, one of them still open;
2546
- * - `ALERT_TYPES` and `ALERTS` - what the alert list shows at 10:30;
2547
- * - `DOWNTIME_MINUTES` - each service's downtime this month, for its
2548
- * availability.
2549
- *
2550
- * "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
2551
- * functions, no imports: copy the file beside an example and it runs.
2552
- */
2553
-
2554
- /** A small LCG - the same numbers on every computer. */
2555
- function random(seed: number): () => number {
2556
- let s = seed >>> 0;
2557
- return () => {
2558
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
2559
- return s / 4294967296;
2560
- };
2561
- }
2562
-
2563
- const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
2564
- const MINUTE = 60_000;
2565
-
2566
- /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
2567
- export const NOW = at(17, 10, 30);
2568
-
2569
- export interface Service {
2570
- id: string;
2571
- name: string;
2572
- team: string;
2573
- /** 1 is customer-facing and pages at night; 3 waits for the morning. */
2574
- tier: 1 | 2 | 3;
2575
- /** The latency objective: the 95th percentile stays below this, in ms. */
2576
- latencySlo: number;
2577
- /** The availability promised for a month, in per cent. */
2578
- availabilityTarget: number;
2579
- }
2580
-
2581
- export const SERVICES: readonly Service[] = [
2582
- { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
2583
- { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
2584
- { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
2585
- { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
2586
- { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
2587
- { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
2588
- { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
2589
- { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
2590
- ];
2591
-
2592
- export interface Engineer {
2593
- id: string;
2594
- name: string;
2595
- team: string;
2596
- }
2597
-
2598
- export const ENGINEERS: readonly Engineer[] = [
2599
- { id: "priya", name: "Priya Raman", team: "Payments" },
2600
- { id: "jonas", name: "Jonas Keller", team: "Payments" },
2601
- { id: "ada", name: "Ada Mwangi", team: "Identity" },
2602
- { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
2603
- { id: "leila", name: "Leila Haddad", team: "Discovery" },
2604
- { id: "sam", name: "Sam Okafor", team: "Messaging" },
2605
- { id: "ines", name: "Ines Duarte", team: "Integrations" },
2606
- { id: "felix", name: "Felix Brandt", team: "Insights" },
2607
- ];
2608
-
2609
- export interface OnCall {
2610
- id: string;
2611
- engineer: string;
2612
- rotation: "primary" | "secondary";
2613
- from: number;
2614
- to: number;
2615
- }
2616
-
2617
- /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
2618
- secondary of one day is the primary of the next. */
2619
- export const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
2620
- { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
2621
- { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
2622
- ].map(({ rotation, engineer }) => ({
2623
- id: `${rotation}-${16 + day}`,
2624
- engineer,
2625
- rotation,
2626
- from: at(16 + day, 9),
2627
- to: at(17 + day, 9),
2628
- }))).flat();
2629
-
2630
- export interface MetricPoint {
2631
- t: number;
2632
- /** Median and 95th percentile latency, in ms. */
2633
- p50: number;
2634
- p95: number;
2635
- /** Failed requests, in per cent. */
2636
- errorRate: number;
2637
- /** Requests per minute. */
2638
- requests: number;
2639
- }
2640
-
2641
- /** Requests per minute at the busiest hour, by tier. */
2642
- const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
2643
-
2644
- /** A service's latency and error rate every five minutes of today, from
2645
- midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
2646
- climbs well past its objective from 09:40 to 10:10, and its errors with it. */
2647
- export function metrics(serviceId: string): MetricPoint[] {
2648
- const index = SERVICES.findIndex((one) => one.id === serviceId);
2649
- const service = SERVICES[index];
2650
- if (service === undefined) throw new Error(`No service "${serviceId}".`);
2651
- const r = random(1000 + index * 37);
2652
- const points: MetricPoint[] = [];
2653
- let drift = 0;
2654
- for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
2655
- const hour = (t - at(17, 0)) / (60 * MINUTE);
2656
- const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
2657
- drift = 0.8 * drift + (r() - 0.5) * 0.08;
2658
- const base = service.latencySlo * (0.45 + 0.2 * load + drift);
2659
- const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
2660
- const p95 = Math.round(base * surge * (1 + r() * 0.08));
2661
- points.push({
2662
- t,
2663
- p50: Math.round(p95 * (0.42 + r() * 0.06)),
2664
- p95,
2665
- errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
2666
- requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
2667
- });
2668
- }
2669
- return points;
2670
- }
2671
-
2672
- export interface Incident {
2673
- id: string;
2674
- title: string;
2675
- service: string;
2676
- severity: "SEV1" | "SEV2" | "SEV3";
2677
- opened: number;
2678
- acknowledged?: number;
2679
- resolved?: number;
2680
- /** An engineer's id. */
2681
- assignee: string;
2682
- }
2683
-
2684
- export const INCIDENTS: readonly Incident[] = [
2685
- { 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" },
2686
- { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
2687
- { 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" },
2688
- { 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" },
2689
- { 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" },
2690
- { 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" },
2691
- { 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" },
2692
- { 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" },
2693
- ];
2694
-
2695
- /** A kind of alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
2696
- export interface AlertType {
2697
- id: string;
2698
- label: string;
2699
- priority: "high" | "medium" | "low";
2700
- }
2701
-
2702
- export const ALERT_TYPES: readonly AlertType[] = [
2703
- { id: "checkout-latency", label: "Checkout · p95 latency above 300 ms", priority: "high" },
2704
- { id: "checkout-errors", label: "Checkout · error rate above 2\u00a0%", priority: "high" },
2705
- { id: "webhooks-queue", label: "Webhooks · queue older than 5 min", priority: "medium" },
2706
- { id: "search-latency", label: "Search · p95 latency above 250 ms", priority: "medium" },
2707
- { id: "images-disk", label: "Image service · disk 85 % full", priority: "low" },
2708
- { id: "reports-job", label: "Reporting · nightly job late", priority: "low" },
2709
- ];
2710
-
2711
- /** One alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
2712
- export type Alert = {
2713
- id: string;
2714
- type: string;
2715
- lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
2716
- raised: number;
2717
- resolved?: number;
2718
- acknowledgedAt?: number;
2719
- } & (
2720
- | { availability?: "in-service" | "suppressed" | "disabled"; snooze?: undefined }
2721
- | { availability: "snoozed"; snooze: { until: number; by: string } }
2722
- );
2723
-
2724
- /** The alerts as they stand at 10:30. */
2725
- export const ALERTS: readonly Alert[] = [
2726
- { id: "a-1", type: "checkout-latency", lifecycle: "active-acknowledged", raised: at(17, 9, 41), acknowledgedAt: at(17, 9, 46) },
2727
- { id: "a-2", type: "checkout-errors", lifecycle: "resolved-unacknowledged", raised: at(17, 9, 43), resolved: at(17, 10, 11) },
2728
- { id: "a-3", type: "webhooks-queue", lifecycle: "active-acknowledged", raised: at(17, 7, 12), acknowledgedAt: at(17, 8, 2) },
2729
- { id: "a-4", type: "search-latency", lifecycle: "active-unacknowledged", raised: at(17, 10, 24) },
2730
- { id: "a-5", type: "search-latency", lifecycle: "resolved-acknowledged", raised: at(17, 8, 5), resolved: at(17, 8, 9), acknowledgedAt: at(17, 8, 6) },
2731
- { id: "a-6", type: "images-disk", lifecycle: "active-unacknowledged", raised: at(17, 6, 0), availability: "snoozed", snooze: { until: at(17, 14), by: "Tomasz Nowak" } },
2732
- { id: "a-7", type: "reports-job", lifecycle: "active-unacknowledged", raised: at(17, 4, 30), availability: "disabled" },
2733
- ];
2734
-
2735
- /** The month so far: 1 March 00:00 to now, in minutes. */
2736
- export const MONTH_MINUTES = (NOW - at(1, 0)) / MINUTE;
2737
-
2738
- /** Minutes each service was down this month. With `MONTH_MINUTES` and the
2739
- service's target, what an availability is worked out from. */
2740
- export const DOWNTIME_MINUTES: Readonly<Record<string, number>> = {
2741
- checkout: 34,
2742
- billing: 18,
2743
- "sign-in": 41,
2744
- search: 85,
2745
- images: 120,
2746
- notifications: 12,
2747
- webhooks: 210,
2748
- reports: 95,
2749
- };
2750
-
2751
- /* ---------------------------------------------------------------------------
2752
- For @umriss-ui/charts: last week at any resolution, a load test, single
2753
- traced requests, the autoscaler's log, the expected range and yesterday's
2754
- success rates.
2755
- --------------------------------------------------------------------------- */
2756
-
2757
- const HOUR = 60 * MINUTE;
2758
- const DAY = 24 * HOUR;
2759
-
2760
- /** Monday, 9 March 2026, 00:00 - the start of last week. */
2761
- export const LAST_WEEK = at(9, 0);
2762
-
2763
- /** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
2764
- const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
2765
-
2766
- /** A service's last week, Monday to Monday, one point every `step`
2767
- milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
2768
- 95th percentile more than doubles and its errors climb. The hour is counted,
2769
- not asked of a Date - a week of seconds is 604,800 points. */
2770
- export function week(serviceId: string, step: number): MetricPoint[] {
2771
- const index = SERVICES.findIndex((one) => one.id === serviceId);
2772
- const service = SERVICES[index];
2773
- if (service === undefined) throw new Error(`No service "${serviceId}".`);
2774
- const r = random(2000 + index * 37);
2775
- const n = Math.floor((7 * DAY) / step);
2776
- const points = new Array<MetricPoint>(n);
2777
- /* The drift wanders at the same pace whatever the step. */
2778
- const keep = Math.pow(0.8, step / (5 * MINUTE));
2779
- const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
2780
- let drift = 0;
2781
- for (let i = 0; i < n; i++) {
2782
- const offset = i * step;
2783
- const day = Math.floor(offset / DAY);
2784
- const hour = (offset % DAY) / HOUR;
2785
- const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
2786
- drift = keep * drift + (r() - 0.5) * kick;
2787
- const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
2788
- const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
2789
- points[i] = {
2790
- t: LAST_WEEK + offset,
2791
- p50: Math.round(p95 * (0.42 + r() * 0.06)),
2792
- p95,
2793
- errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
2794
- requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
2795
- };
2796
- }
2797
- return points;
2798
- }
2799
-
2800
- /** When this morning's load test of Search began. */
2801
- export interface LoadTestPoint {
2802
- /** Minutes since the test began. */
2803
- minute: number;
2804
- /** CPU load of the busiest node, in per cent. */
2805
- cpu: number;
2806
- requestsPerHour: number;
2807
- /** 95th percentile latency, in ms. */
2808
- p95: number;
2809
- }
2810
-
2811
- /** An hour of load test, minute by minute: three magnitudes far apart. */
2812
- export const LOAD_TEST: readonly LoadTestPoint[] = (() => {
2813
- const r = random(99);
2814
- let cpu = 21;
2815
- let requestsPerHour = 128_000;
2816
- let p95 = 640;
2817
- return Array.from({ length: 60 }, (_, minute) => {
2818
- cpu += (r() - 0.5) * 0.9;
2819
- requestsPerHour += (r() - 0.5) * 9000;
2820
- p95 += (r() - 0.5) * 40;
2821
- return { minute, cpu, requestsPerHour, p95 };
2822
- });
2823
- })();
2824
-
2825
- export interface Trace {
2826
- t: number;
2827
- /** How long the request took, end to end, in ms. */
2828
- ms: number;
2829
- }
2830
-
2831
- /** Single Checkout requests traced end to end this morning, one every few
2832
- minutes from 06:00 - individual measurements, nothing in between. Slow
2833
- during the bad half hour, and now and then on their own. */
2834
- export const TRACES: readonly Trace[] = (() => {
2835
- const r = random(4040);
2836
- const traces: Trace[] = [];
2837
- for (let t = at(17, 6, 2); t <= NOW; t += Math.round(1 + r() * 4) * MINUTE) {
2838
- const bad = t >= at(17, 9, 40) && t < at(17, 10, 10);
2839
- const ms = 150 + r() * 90 + (bad ? 110 + r() * 120 : r() < 0.05 ? 150 : 0);
2840
- traces.push({ t, ms: Math.round(ms) });
2841
- }
2842
- return traces;
2843
- })();
2844
-
2845
- export interface ReplicaChange {
2846
- t: number;
2847
- /** Checkout's instances from here on; `null` while the autoscaler was paused. */
2848
- replicas: number | null;
2849
- }
2850
-
2851
- /** Checkout's instances today, logged only when the autoscaler changes them.
2852
- It was paused for a database upgrade from 03:00 to 04:00; the last entry,
2853
- at now, closes the hold. */
2854
- export const REPLICAS: readonly ReplicaChange[] = [
2855
- { t: at(17, 0), replicas: 4 },
2856
- { t: at(17, 3), replicas: null },
2857
- { t: at(17, 4), replicas: 3 },
2858
- { t: at(17, 6, 30), replicas: 5 },
2859
- { t: at(17, 8), replicas: 8 },
2860
- { t: at(17, 9, 45), replicas: 12 },
2861
- { t: at(17, 10, 15), replicas: 9 },
2862
- { t: NOW, replicas: 9 },
2863
- ];
2864
-
2865
- export interface ExpectedPoint {
2866
- t: number;
2867
- /** The range the requests per minute are expected in; `null` where the
2868
- job that computes it did not run. */
2869
- low: number | null;
2870
- high: number | null;
2871
- }
2872
-
2873
- /** The range a service's requests per minute are expected in today, every
2874
- five minutes - the day's usual curve, give or take 15 %. The job that
2875
- computes it skipped 03:00 to 04:00. */
2876
- export function expected(serviceId: string): ExpectedPoint[] {
2877
- const service = SERVICES.find((one) => one.id === serviceId);
2878
- if (service === undefined) throw new Error(`No service "${serviceId}".`);
2879
- const points: ExpectedPoint[] = [];
2880
- for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
2881
- const hour = (t - at(17, 0)) / HOUR;
2882
- const usual = VOLUME[service.tier] * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
2883
- const skipped = hour >= 3 && hour < 4;
2884
- points.push({ t, low: skipped ? null : Math.round(usual * 0.85), high: skipped ? null : Math.round(usual * 1.15) });
2885
- }
2886
- return points;
2887
- }
2888
-
2889
- export interface SuccessCell {
2890
- hour: number;
2891
- /** The row: the service's place in `SERVICES`. */
2892
- service: number;
2893
- /** Requests that succeeded, in per cent; `null` where there were none. */
2894
- success: number | null;
2895
- }
2896
-
2897
- /** Yesterday, per service and hour: the share of requests that succeeded.
2898
- Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
2899
- and Reporting takes no requests before 06:00 - no requests, no share. */
2900
- export const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
2901
- const r = random(23);
2902
- const cells: SuccessCell[] = [];
2903
- SERVICES.forEach((one, service) => {
2904
- for (let hour = 0; hour < 24; hour++) {
2905
- let success: number | null = 99.2 + r() * 0.8;
2906
- if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
2907
- if (hour === 3) success = 97.4 + r() * 1;
2908
- if (one.id === "reports" && hour < 6) success = null;
2909
- cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
2910
- }
2911
- });
2912
- return cells;
2913
- })();
2914
- ```
2915
-
2916
- ### `planning.ts`
2917
-
2918
- ```ts
2919
- /**
2920
- * The planning world: Tidewell, a small studio that builds web and mobile
2921
- * apps for its clients, in two teams.
2922
- *
2923
- * The standing cast:
2924
- * - `PEOPLE` - ten people with their role, team and weekly capacity in hours;
2925
- * - `PROJECTS` - four client projects, each with a colour;
2926
- * - `SPRINTS` - two-week sprints; sprint 14 runs now, 9 to 20 March;
2927
- * - `WORK` - the current sprint's work items with an estimate and a status,
2928
- * placed on a person over time; structurally a `Subtask` of
2929
- * `@umriss-ui/schedule` (`task` is the project, `lane` the person);
2930
- * - `LEAVE` - holidays, sick days and training, to become blocked time;
2931
- * - `BURNDOWN` - sprint 14's remaining hours at the end of each working day,
2932
- * up to yesterday.
2933
- *
2934
- * "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
2935
- * functions, no imports: copy the file beside an example and it runs.
2936
- */
2937
-
2938
- const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
2939
-
2940
- /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
2941
- export const NOW = at(17, 10, 30);
2942
-
2943
- export interface Person {
2944
- id: string;
2945
- name: string;
2946
- role: "Developer" | "Designer" | "Product manager" | "QA engineer";
2947
- team: "Web" | "Apps";
2948
- /** Hours a week they can be planned for. */
2949
- capacity: number;
2950
- }
2951
-
2952
- export const PEOPLE: readonly Person[] = [
2953
- { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
2954
- { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
2955
- { id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
2956
- { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
2957
- { id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
2958
- { id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
2959
- { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
2960
- { id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
2961
- { id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
2962
- { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
2963
- ];
2964
-
2965
- /** A project, shaped as `@umriss-ui/schedule`'s `Task`: an id, a name and a
2966
- colour in both schemes. */
2967
- export interface Project {
2968
- id: string;
2969
- name: string;
2970
- client: string;
2971
- color: string;
2972
- }
2973
-
2974
- export const PROJECTS: readonly Project[] = [
2975
- { id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
2976
- { id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
2977
- { id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
2978
- { id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
2979
- ];
2980
-
2981
- export interface Sprint {
2982
- id: string;
2983
- name: string;
2984
- from: number;
2985
- to: number;
2986
- goal: string;
2987
- }
2988
-
2989
- /** Two weeks each, Monday 09:00 to the second Friday 17:00. */
2990
- export const SPRINTS: readonly Sprint[] = [
2991
- { name: "Sprint 12", day: 9 - 28, goal: "Shop checkout on the new design" },
2992
- { name: "Sprint 13", day: 9 - 14, goal: "Booking app in the stores' beta" },
2993
- { name: "Sprint 14", day: 9, goal: "Member portal sign-in and profile" },
2994
- { name: "Sprint 15", day: 23, goal: "Shop search and filters" },
2995
- { name: "Sprint 16", day: 37, goal: "Booking reminders" },
2996
- ].map(({ name, day, goal }) => ({ id: name.toLowerCase().replace(" ", "-"), name, from: at(day), to: at(day + 11, 17), goal }));
2997
-
2998
- /** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
2999
- export interface WorkItem {
3000
- id: string;
3001
- /** The project. */
3002
- task: string;
3003
- /** The person. */
3004
- lane: string;
3005
- from: number;
3006
- to: number;
3007
- name: string;
3008
- sprint: string;
3009
- /** Hours, as estimated at planning. */
3010
- estimate: number;
3011
- status: "to do" | "in progress" | "in review" | "done";
3012
- }
3013
-
3014
- const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
3015
- ({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
3016
-
3017
- export const WORK: readonly WorkItem[] = [
3018
- item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
3019
- item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
3020
- item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
3021
- item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
3022
- item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
3023
- item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
3024
- item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
3025
- item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
3026
- item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
3027
- item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
3028
- item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
3029
- item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
3030
- item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
3031
- item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
3032
- item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
3033
- item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
3034
- ];
3035
-
3036
- export interface Leave {
3037
- id: string;
3038
- person: string;
3039
- from: number;
3040
- to: number;
3041
- reason: "Holiday" | "Sick" | "Training";
3042
- }
3043
-
3044
- /** When people are away: blocked time on their lane. */
3045
- export const LEAVE: readonly Leave[] = [
3046
- { id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
3047
- { id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
3048
- { id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
3049
- { id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
3050
- { id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
3051
- { id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
3052
- ];
3053
-
3054
- export interface BurndownPoint {
3055
- /** The working day, at 17:00. */
3056
- t: number;
3057
- /** Where the line would be, burning evenly. */
3058
- ideal: number;
3059
- /** Hours left, `null` for the days still to come. */
3060
- remaining: number | null;
3061
- }
3062
-
3063
- /** Sprint 14's ten working days. The team fell behind in the first week, when
3064
- two were away, and is catching up. */
3065
- export const BURNDOWN: readonly BurndownPoint[] = (() => {
3066
- const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
3067
- const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
3068
- const burnt = [0, 22, 38, 60, 71, 98];
3069
- return days.map((day, i) => ({
3070
- t: at(day, 17),
3071
- ideal: Math.round(total * (1 - i / (days.length - 1))),
3072
- remaining: burnt[i] === undefined ? null : total - burnt[i]!,
3073
- }));
3074
- })();
3075
- ```
3076
-
3077
- ### `plant.ts`
3078
-
3079
- ```ts
3080
- /**
3081
- * The plant world: Brenholt Tile Works, a tile maker with one kiln line, and the
3082
- * machine shop next door.
3083
- *
3084
- * The standing cast:
3085
- * - the kiln line - press, dryer D1 and kiln K1 - over one early shift, minute
3086
- * by minute: readings, samples, batches and the alarms they raise (`plant`);
3087
- * - the machine shop's plan for Tuesday, 17 March 2026: seven stations, six
3088
- * orders, their steps and the dependencies between them (`STATIONS`,
3089
- * `ORDERS`, `STEPS`, `MOVES`).
3090
- *
3091
- * Plain data and small pure functions, no imports: copy the file beside an
3092
- * example and it runs. Seeded, so the same seed gives the same numbers on
3093
- * every machine; nothing here reads a clock.
3094
- */
3095
-
3096
- /* The few shapes the umriss packages expect, written out so this file needs
3097
- no import. The data is structurally what the packages take. */
3098
-
3099
- /** A limit set as `@umriss-ui/core` reads it. */
3100
- interface LimitSet {
3101
- target?: number;
3102
- limits?: readonly { value: number; side: "upper" | "lower"; severity: "warning" | "alarm" }[];
3103
- }
3104
-
3105
- /** An alarm's dead band, as `@umriss-ui/table` reads it. */
3106
- interface ReturnBand {
3107
- direction: "upper" | "lower";
3108
- limit: number;
3109
- returnTo: number;
3110
- }
3111
-
3112
- /** A kind of alarm, as `@umriss-ui/table` reads it. */
3113
- interface AlarmType {
3114
- id: string;
3115
- label: string;
3116
- priority: "high" | "medium" | "low";
3117
- returnBand?: ReturnBand;
3118
- }
3119
-
3120
- /** One alarm, as `@umriss-ui/table` reads it. */
3121
- interface Alarm {
3122
- id: string;
3123
- type: string;
3124
- lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
3125
- raised: number;
3126
- resolved?: number;
3127
- acknowledgedAt?: number;
3128
- availability?: "in-service" | "suppressed" | "disabled";
3129
- }
3130
-
3131
- /** A task, a subtask and a dependency, as `@umriss-ui/schedule` reads them. */
3132
- interface Task {
3133
- readonly id: string;
3134
- readonly name?: string;
3135
- readonly color: string;
3136
- }
3137
- interface Subtask {
3138
- readonly id: string;
3139
- readonly task: string;
3140
- readonly lane: string;
3141
- readonly from: number;
3142
- readonly to: number;
3143
- readonly leadIn?: number;
3144
- readonly leadOut?: number;
3145
- }
3146
- interface Dependency {
3147
- readonly id: string;
3148
- readonly from: string;
3149
- readonly to: string;
3150
- readonly lag: number;
3151
- readonly leaves?: "main" | "leadOut";
3152
- readonly arrives?: "main" | "leadIn";
3153
- }
3154
-
3155
- /** A small LCG - reproducible across runs and platforms. */
3156
- export function random(seed: number): () => number {
3157
- let s = seed >>> 0;
3158
- return () => {
3159
- s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
3160
- return s / 4294967296;
3161
- };
3162
- }
3163
-
3164
- /* ---------------------------------------------------------------------------
3165
- The kiln line over an early shift.
3166
-
3167
- Every part of a control room reads from this one shift - the trend, the
3168
- tiles, the alarm list, the control chart, the plan and the OEE. The limit
3169
- the kiln crosses in the trend is the alarm in the list, the verdict on the
3170
- tile and the scrap in the OEE, because all four are read off the same
3171
- minute of the same readings. The line counts in minutes from the start of
3172
- the shift, and whoever draws it adds the instant the shift began.
3173
-
3174
- The line generates alarms, which is exactly what the library does not do
3175
- (ADR-0009): it decides that a reading above the limit is an alarm, and when
3176
- it has come back far enough to clear. The table's model takes it from
3177
- there.
3178
- --------------------------------------------------------------------------- */
3179
-
3180
- /** The early shift, 06:00 to 14:00. */
3181
- export const SHIFT_MINUTES = 480;
3182
-
3183
- /** The kiln's zone 3, in °C: the numbers every part reads it against, once.
3184
- Between the two warnings lies the tolerance - a tile fired outside it is
3185
- scrap; `returnTo` is the alarm's dead band. */
3186
- export const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
3187
-
3188
- /** The same numbers as the limit set the tile and the verdict read. */
3189
- export const KILN_LIMITS: LimitSet = {
3190
- target: KILN.target,
3191
- limits: [
3192
- { value: KILN.tolerance[1], side: "upper", severity: "warning" },
3193
- { value: KILN.alarm, side: "upper", severity: "alarm" },
3194
- { value: KILN.tolerance[0], side: "lower", severity: "warning" },
3195
- ],
3196
- };
3197
-
3198
- /** The kiln fires one tile every quarter of a minute when nothing holds it up -
3199
- the ideal cycle time of the OEE's performance. */
3200
- export const IDEAL_CYCLE_MINUTES = 0.25;
3201
-
3202
- /** The kiln alarm's condition and its dead band, as the table's model reads them. */
3203
- const KILN_RETURN: ReturnBand = { direction: "upper", limit: KILN.alarm, returnTo: KILN.returnTo };
3204
-
3205
- /** What the four measuring points of the line can report. */
3206
- export const ALARM_TYPES: readonly AlarmType[] = [
3207
- {
3208
- id: "kiln-high",
3209
- label: "Kiln K1 · zone 3 above alarm limit",
3210
- priority: "high",
3211
- /* The dead band: back below `returnTo`, not merely below the limit, or a
3212
- reading riding on the limit would raise an alarm a minute. */
3213
- returnBand: KILN_RETURN,
3214
- },
3215
- { id: "exit-silent", label: "Kiln K1 · exit pyrometer sends nothing", priority: "medium" },
3216
- { id: "belt-empty", label: "Kiln K1 · belt empty", priority: "medium" },
3217
- { id: "dryer-fan", label: "Dryer D1 · fan vibration high", priority: "low" },
3218
- ];
3219
-
3220
- /** One minute of the line. */
3221
- export interface Reading {
3222
- /** Minutes since the start of the shift. */
3223
- minute: number;
3224
- /** Zone 3 of the kiln, in °C. */
3225
- kiln: number;
3226
- /** The exit pyrometer, in °C - `null` while it sends nothing. */
3227
- exit: number | null;
3228
- /** Whether the belt runs. */
3229
- running: boolean;
3230
- /** Tiles out of the kiln in this minute. */
3231
- fired: number;
3232
- /** Of them, the ones fired inside the tolerance. */
3233
- good: number;
3234
- }
3235
-
3236
- /** A tile taken off the belt and measured - one every ten minutes while it runs. */
3237
- export interface Sample {
3238
- minute: number;
3239
- /** The tile's length after firing, in mm (nominal 600). */
3240
- length: number;
3241
- }
3242
-
3243
- /** A batch of tiles through the three stations. */
3244
- export interface Batch {
3245
- id: string;
3246
- name: string;
3247
- /** How many tiles the batch is planned for. */
3248
- tiles: number;
3249
- /** Minutes since the start of the shift, per station. */
3250
- press: readonly [number, number];
3251
- dryer: readonly [number, number];
3252
- kiln: readonly [number, number];
3253
- }
3254
-
3255
- export interface Plant {
3256
- readings: readonly Reading[];
3257
- samples: readonly Sample[];
3258
- batches: readonly Batch[];
3259
- }
3260
-
3261
- /* When the exit pyrometer goes silent, and for how long. Fixed rather than
3262
- seeded, so that a running control room shows its reading turn stale, then
3263
- lost, and come back. */
3264
- const SILENT_FROM = 288;
3265
- const SILENT_FOR = 50;
3266
-
3267
- /* The dryer's vibration sensor is under maintenance all shift: its alarm is
3268
- active and disabled. */
3269
- const DRYER_FAN_RAISED = 20;
3270
-
3271
- /** The shift, from a seed. */
3272
- export function plant(seed: number): Plant {
3273
- const r = random(seed);
3274
-
3275
- /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
3276
- into the shift, for half an hour, peaking near 1242 °C. The operator stops
3277
- the feed twenty-two minutes in, for eighteen minutes. The noise around it
3278
- is too small to reach a warning on its own, so this is the only crossing -
3279
- which is what the tests hold. */
3280
- const surgeFrom = 150 + Math.floor(r() * 60);
3281
- const surgeFor = 30;
3282
- const stopFrom = surgeFrom + 22;
3283
- const stopFor = 18;
3284
-
3285
- /* The plan: batches through the kiln one after another, each pressed an
3286
- hour and dried half an hour before - the first ones during the night
3287
- shift. Tiles come out of the kiln only while a batch is in it, so the
3288
- OEE's count and the batches' counts are the same tiles. The plan is a
3289
- plan: a stop of the belt does not move it. */
3290
- const batches: Batch[] = [];
3291
- for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
3292
- const length = 70 + Math.floor(r() * 25);
3293
- batches.push({
3294
- id: `b-${4121 + i}`,
3295
- name: `B-${4121 + i}`,
3296
- tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
3297
- press: [at - 60, at - 60 + length],
3298
- dryer: [at - 30, at - 30 + length],
3299
- kiln: [at, at + length],
3300
- });
3301
- at += length + 5;
3302
- }
3303
-
3304
- const readings: Reading[] = [];
3305
- const samples: Sample[] = [];
3306
- let drift = 0;
3307
- for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
3308
- drift = 0.9 * drift + (r() - 0.5) * 3;
3309
- const into = minute - surgeFrom;
3310
- const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
3311
- const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
3312
-
3313
- const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
3314
- const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
3315
- const jam = r() < 0.12;
3316
- const fired = running && loaded ? (jam ? 3 : 4) : 0;
3317
- const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
3318
- const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
3319
- const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
3320
-
3321
- readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
3322
-
3323
- if (running && minute % 10 === 0) {
3324
- /* Hotter shrinks more: the length follows the kiln, so the control
3325
- chart sees the excursion the trend sees. */
3326
- const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
3327
- samples.push({ minute, length: Math.round(length * 100) / 100 });
3328
- }
3329
- }
3330
-
3331
- return { readings, samples, batches };
3332
- }
3333
-
3334
- /** The readings up to and including the minute. */
3335
- export function upTo<T extends { minute: number }>(series: readonly T[], minute: number): readonly T[] {
3336
- return series.filter((one) => one.minute <= minute);
3337
- }
3338
-
3339
- /** How long ago the exit pyrometer last reported, in minutes - `0` while it
3340
- reports. What the tile's freshness is told. */
3341
- export function exitSilence(p: Plant, minute: number): number {
3342
- for (let m = minute; m >= 0; m--) {
3343
- if (p.readings[m]?.exit !== null) return minute - m;
3344
- }
3345
- return minute;
3346
- }
3347
-
3348
- /** When a condition held, as spans of minutes: `[from, to)`, `to` absent while
3349
- it still holds at the minute. */
3350
- function spans(
3351
- readings: readonly Reading[],
3352
- minute: number,
3353
- raise: (one: Reading) => boolean,
3354
- clear: (one: Reading) => boolean,
3355
- ): { from: number; to?: number }[] {
3356
- const found: { from: number; to?: number }[] = [];
3357
- let open: { from: number; to?: number } | null = null;
3358
- for (const one of readings) {
3359
- if (one.minute > minute) break;
3360
- if (open === null && raise(one)) {
3361
- open = { from: one.minute };
3362
- found.push(open);
3363
- } else if (open !== null && clear(one)) {
3364
- open.to = one.minute;
3365
- open = null;
3366
- }
3367
- }
3368
- return found;
3369
- }
3370
-
3371
- /**
3372
- * The alarms of the shift as they stand at the minute: raised, resolved - and
3373
- * acknowledged where the operator did, at the instant he did.
3374
- *
3375
- * `start` is the instant the shift began; the alarms carry instants because the
3376
- * table's model does.
3377
- */
3378
- export function alarmsAt(
3379
- p: Plant,
3380
- minute: number,
3381
- start: number,
3382
- acknowledged: ReadonlyMap<string, number> = new Map(),
3383
- ): Alarm[] {
3384
- const instant = (m: number) => start + m * 60_000;
3385
- const alarms: Alarm[] = [];
3386
-
3387
- const add = (
3388
- type: string,
3389
- span: { from: number; to?: number },
3390
- extra: { availability?: "suppressed" | "disabled" } = {},
3391
- ) => {
3392
- alarms.push({
3393
- id: `${type}-${span.from}`,
3394
- type,
3395
- lifecycle: span.to === undefined ? "active-unacknowledged" : "resolved-unacknowledged",
3396
- raised: instant(span.from),
3397
- ...(span.to === undefined ? {} : { resolved: instant(span.to) }),
3398
- ...extra,
3399
- });
3400
- };
3401
-
3402
- for (const span of spans(p.readings, minute, (one) => one.kiln > KILN_RETURN.limit, (one) => one.kiln <= KILN_RETURN.returnTo)) {
3403
- add("kiln-high", span);
3404
- }
3405
- for (const span of spans(p.readings, minute, (one) => one.exit === null, (one) => one.exit !== null)) {
3406
- add("exit-silent", span);
3407
- }
3408
- /* The belt runs empty because the operator stopped the feed: the plant's
3409
- logic knows it, so the alarm is suppressed - there, and neutral. */
3410
- for (const span of spans(p.readings, minute, (one) => !one.running, (one) => one.running)) {
3411
- add("belt-empty", span, { availability: "suppressed" });
3412
- }
3413
- if (minute >= DRYER_FAN_RAISED) {
3414
- add("dryer-fan", { from: DRYER_FAN_RAISED }, { availability: "disabled" });
3415
- }
3416
-
3417
- return alarms.map((alarm) => {
3418
- const at = acknowledged.get(alarm.id);
3419
- if (at === undefined) return alarm;
3420
- const lifecycle = alarm.resolved === undefined ? "active-acknowledged" : "resolved-acknowledged";
3421
- return { ...alarm, lifecycle, acknowledgedAt: at };
3422
- });
3423
- }
3424
-
3425
- /** The shift's figures for the OEE, up to and including the minute. */
3426
- export interface ShiftCount {
3427
- /** Minutes of the shift so far. */
3428
- planned: number;
3429
- /** Minutes the belt stood. */
3430
- downtime: number;
3431
- /** Tiles out of the kiln. */
3432
- total: number;
3433
- /** Of them, inside the tolerance. */
3434
- good: number;
3435
- }
3436
-
3437
- export function countAt(p: Plant, minute: number): ShiftCount {
3438
- const so = upTo(p.readings, minute);
3439
- return {
3440
- planned: so.length,
3441
- downtime: so.filter((one) => !one.running).length,
3442
- total: so.reduce((sum, one) => sum + one.fired, 0),
3443
- good: so.reduce((sum, one) => sum + one.good, 0),
3444
- };
3445
- }
3446
-
3447
- /* ---------------------------------------------------------------------------
3448
- The machine shop's plan: seven stations, six orders on their way through
3449
- them on Tuesday, 17 March 2026 - the day the browser suites freeze the
3450
- clock on.
3451
-
3452
- Written out, not generated from a seed: a planner reads these as a day's
3453
- plan, and a finding is placed where it is on purpose - the overlap on the
3454
- mill, the violated dependency into the paint shop.
3455
- --------------------------------------------------------------------------- */
3456
-
3457
- const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
3458
- const min = (n: number) => n * 60_000;
3459
-
3460
- export const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)];
3461
-
3462
- export const STATIONS = [
3463
- { id: "saw", label: "Saw 1" },
3464
- { id: "lathe-1", label: "Lathe 1" },
3465
- { id: "lathe-2", label: "Lathe 2" },
3466
- { id: "mill", label: "Mill" },
3467
- { id: "press", label: "Press 2" },
3468
- { id: "paint", label: "Paint shop" },
3469
- { id: "qa", label: "Inspection" },
3470
- ] as const;
3471
-
3472
- /* Colours in both schemes, as the application's own tokens would give them. */
3473
- export const ORDERS: readonly Task[] = [
3474
- { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
3475
- { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
3476
- { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
3477
- { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
3478
- { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
3479
- { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
3480
- ];
3481
-
3482
- export const STEPS: readonly Subtask[] = [
3483
- { id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) },
3484
- { id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) },
3485
- { id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) },
3486
-
3487
- { id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) },
3488
- { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) },
3489
- { id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) },
3490
-
3491
- { id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) },
3492
- { id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) },
3493
- { id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) },
3494
-
3495
- { id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) },
3496
- { id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) },
3497
- { id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) },
3498
-
3499
- { id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) },
3500
- { id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) },
3501
- { id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) },
3502
-
3503
- { id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) },
3504
- { id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) },
3505
- { id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) },
3506
- ];
3507
-
3508
- export const MOVES: readonly Dependency[] = [
3509
- { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) },
3510
- { id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) },
3511
- { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) },
3512
- { id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" },
3513
- { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) },
3514
- /* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
3515
- lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */
3516
- { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) },
3517
- { id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) },
3518
- { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" },
3519
- { id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) },
3520
- { id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) },
3521
- { id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) },
3522
- { id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" },
3523
- ];
3524
- ```