@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/CHANGELOG.md +14 -0
- package/dist/calculation.css +1 -1
- package/dist/calculation.js +72 -72
- package/docs/llms-full.md +1360 -1870
- package/package.json +3 -3
package/docs/llms-full.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @umriss-ui/calculation 0.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
## Getting started
|
|
771
|
+
/** The early shift, 06:00 to 14:00. */
|
|
772
|
+
const SHIFT_MINUTES = 480;
|
|
524
773
|
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
818
|
+
interface Plant {
|
|
819
|
+
readings: readonly Reading[];
|
|
820
|
+
samples: readonly Sample[];
|
|
821
|
+
batches: readonly Batch[];
|
|
822
|
+
}
|
|
538
823
|
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1082
|
+
/* Data from the plant world, written out here so the example runs on its own. */
|
|
617
1083
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
1093
|
+
/** The early shift, 06:00 to 14:00. */
|
|
1094
|
+
const SHIFT_MINUTES = 480;
|
|
647
1095
|
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
```
|