@umriss-ui/calculation 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/llms-full.md CHANGED
@@ -1,8 +1,568 @@
1
- # @umriss-ui/calculation 0.3.0
1
+ # @umriss-ui/calculation 0.3.2
2
2
 
3
- > The umriss calculation - a derivation written as it is shown, evaluated by the library, folded and read line by line.
3
+ > The umriss calculation for data-dense applications - a derivation written as it is shown, evaluated by the library, folded and read line by line.
4
4
 
5
- Install with `pnpm add @umriss-ui/calculation`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/calculation/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and why it is built the way it is. The pages index stands in https://romanhaendler.github.io/umriss-ui/calculation/llms.txt.
5
+ Install with `pnpm add @umriss-ui/calculation`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/calculation/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and what it deliberately does not do. The pages index stands in https://romanhaendler.github.io/umriss-ui/calculation/llms.txt.
6
+
7
+ ## Scenarios
8
+
9
+ Composed, realistic screens built from the package. A numbered mark on the screen is an element with `data-callout`.
10
+
11
+ ### Check a service's availability for the month
12
+
13
+ A team lead at Quillmere checks, before the monthly review, whether a service keeps its availability promise and how much error budget is left.
14
+
15
+ 1. The service is chosen here; every figure on the screen follows the choice.
16
+ 2. The tiles give the answer at a glance, read against the service's target.
17
+ 3. The calculation shows where the availability comes from: the month so far, minus the downtime, divided by the month. The verdict beside it is the same one the tile shows.
18
+ 4. The error budget is the downtime the target allows; what is left can turn negative, and then it is marked.
19
+ 5. The incidents of the week that touched the service stand beside the figures, so the downtime has names.
20
+
21
+ Built from: Calculation, Tree, Given, Select, Stat, Card, Badge.
22
+
23
+ ```tsx
24
+ import { useState } from "react";
25
+ import { Badge, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Stat, Text } from "@umriss-ui/core";
26
+ import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
27
+ import { DOWNTIME_MINUTES, INCIDENTS, MONTH_MINUTES, SERVICES } from "./operations";
28
+
29
+ const SEVERITY_TONE = { SEV1: "danger", SEV2: "warning", SEV3: "neutral" } as const;
30
+
31
+ export default function ServiceAvailability() {
32
+ const [serviceId, setServiceId] = useState("checkout");
33
+ const service = SERVICES.find((one) => one.id === serviceId) ?? SERVICES[0]!;
34
+ const downtime = DOWNTIME_MINUTES[service.id] ?? 0;
35
+ /* Targets are stated in per cent; the calculation works in ratios. */
36
+ const target = service.availabilityTarget / 100;
37
+ const availability = (MONTH_MINUTES - downtime) / MONTH_MINUTES;
38
+ const allowed = MONTH_MINUTES * (1 - target);
39
+ const incidents = INCIDENTS.filter((one) => one.service === service.id);
40
+
41
+ return (
42
+ <Stack gap={4}>
43
+ <Stack direction="row" gap={4} align="end" wrap>
44
+ <FormField label="Service" data-callout="1">
45
+ <Select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
46
+ {SERVICES.map((one) => (
47
+ <option key={one.id} value={one.id}>
48
+ {one.name}
49
+ </option>
50
+ ))}
51
+ </Select>
52
+ </FormField>
53
+ <Text size="sm" tone="muted">
54
+ {service.team} · tier {service.tier} · promised {service.availabilityTarget} % · 1 March to now
55
+ </Text>
56
+ </Stack>
57
+
58
+ <Grid minItemWidth="200px" gap={4} data-callout="2">
59
+ <Stat
60
+ label="Availability this month"
61
+ value={availability * 100}
62
+ unit="%"
63
+ decimals={3}
64
+ limits={{ target: service.availabilityTarget, limits: [{ value: service.availabilityTarget, side: "lower", severity: "alarm" }] }}
65
+ />
66
+ <Stat label="Downtime" value={downtime} unit="min" />
67
+ <Stat
68
+ label="Error budget left"
69
+ value={allowed - downtime}
70
+ unit="min"
71
+ decimals={1}
72
+ limits={{ limits: [{ value: 0, side: "lower", severity: "alarm" }] }}
73
+ />
74
+ </Grid>
75
+
76
+ <Grid minItemWidth="320px" gap={4}>
77
+ <Card>
78
+ <CardHeader title="How the availability comes about" />
79
+ <CardBody data-callout="3">
80
+ <Calculation aria-label={`Availability of ${service.name}, this month`}>
81
+ <Quotient
82
+ label="Availability"
83
+ format="percent"
84
+ decimals={3}
85
+ target={target}
86
+ limits={[{ value: target, side: "lower", severity: "alarm" }]}
87
+ >
88
+ <Difference label="Minutes up" unit="min">
89
+ <Given id="month" label="Minutes this month" value={MONTH_MINUTES} unit="min" source="1 March 00:00 to now" />
90
+ <Given label="Downtime" value={downtime} unit="min" source="Status page" />
91
+ </Difference>
92
+ <Ref to="month" />
93
+ </Quotient>
94
+ </Calculation>
95
+ </CardBody>
96
+ </Card>
97
+
98
+ <Card>
99
+ <CardHeader title="Error budget" />
100
+ <CardBody data-callout="4">
101
+ <Calculation aria-label={`Error budget of ${service.name}, this month`}>
102
+ <Difference label="Error budget left" unit="min" decimals={1} limits={[{ value: 0, side: "lower", severity: "alarm" }]}>
103
+ <Product label="Error budget" unit="min" decimals={1}>
104
+ <Given label="Minutes this month" value={MONTH_MINUTES} unit="min" />
105
+ <Given label="Allowed unavailability" value={1 - target} format="percent" decimals={2} source="Service level agreement" />
106
+ </Product>
107
+ <Given label="Downtime" value={downtime} unit="min" source="Status page" />
108
+ </Difference>
109
+ </Calculation>
110
+ </CardBody>
111
+ </Card>
112
+ </Grid>
113
+
114
+ <Card>
115
+ <CardHeader title="Incidents this week" />
116
+ <CardBody data-callout="5">
117
+ {incidents.length === 0 ? (
118
+ <Text tone="muted">No incident touched {service.name} this week.</Text>
119
+ ) : (
120
+ <Stack gap={2}>
121
+ {incidents.map((one) => (
122
+ <Stack key={one.id} direction="row" gap={3} align="center" wrap>
123
+ <Badge tone={SEVERITY_TONE[one.severity]}>{one.severity}</Badge>
124
+ <Text mono size="sm">
125
+ {one.id}
126
+ </Text>
127
+ <Text size="sm">{one.title}</Text>
128
+ <Text size="sm" tone="muted">
129
+ {one.resolved === undefined ? "still open" : "resolved"}
130
+ </Text>
131
+ </Stack>
132
+ ))}
133
+ </Stack>
134
+ )}
135
+ </CardBody>
136
+ </Card>
137
+ </Stack>
138
+ );
139
+ }
140
+ ```
141
+
142
+ It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
143
+
144
+ ### Price a tour
145
+
146
+ A dispatcher at Ferrow Parcel prices a day's tour - energy, driver and vehicle - to see what each stop costs before quoting a customer.
147
+
148
+ 1. The tour is chosen here; vehicle, driver and depot come with it.
149
+ 2. The hours run from the start of loading to the return to the depot - the figure the driver's cost starts from.
150
+ 3. Late stops are counted beside the stops: a cheap tour that misses its windows is no bargain.
151
+ 4. The cost per stop, worked out in full: the tour's cost divided by its stops. Energy, driver and vehicle each fold open to the numbers they come from, and each number names its source.
152
+ 5. The rates the calculation uses, as the fleet office keeps them; a changed price is found here and in the line where it enters.
153
+
154
+ Built from: Calculation, Tree, Given, Select, Stat, Card, Badge.
155
+
156
+ ```tsx
157
+ import { useState } from "react";
158
+ import { Badge, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Stat, Text } from "@umriss-ui/core";
159
+ import { Calculation, Given, Product, Quotient, Sum } from "@umriss-ui/calculation";
160
+ import { COST_RATES, DEPOTS, DRIVERS, TOURS, VEHICLES } from "./logistics";
161
+
162
+ const TYPE_NAME = { van: "Van", "e-van": "Electric van", truck: "Truck" } as const;
163
+ const time = (at: number) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
164
+
165
+ export default function PriceATour() {
166
+ const [tourId, setTourId] = useState(TOURS[0]!.id);
167
+ const tour = TOURS.find((one) => one.id === tourId) ?? TOURS[0]!;
168
+ const vehicle = VEHICLES.find((one) => one.id === tour.vehicle)!;
169
+ const driver = DRIVERS.find((one) => one.id === tour.driver)!;
170
+ const depot = DEPOTS.find((one) => one.id === tour.depot)!;
171
+ const electric = vehicle.type === "e-van";
172
+ const hours = Math.round(((tour.to - tour.from) / 3_600_000 + tour.loading / 60) * 100) / 100;
173
+ const late = tour.stops.filter((stop) => stop.arrival > stop.window[1]).length;
174
+
175
+ return (
176
+ <Stack gap={4}>
177
+ <Stack direction="row" gap={4} align="end" wrap>
178
+ <FormField label="Tour" data-callout="1">
179
+ <Select value={tourId} onChange={(event) => setTourId(event.target.value)}>
180
+ {TOURS.map((one) => (
181
+ <option key={one.id} value={one.id}>
182
+ {one.id} · {VEHICLES.find((v) => v.id === one.vehicle)!.plate}
183
+ </option>
184
+ ))}
185
+ </Select>
186
+ </FormField>
187
+ <Text size="sm" tone="muted">
188
+ {TYPE_NAME[vehicle.type]} {vehicle.plate} · {driver.name} · {depot.name} · {time(tour.from)} to {time(tour.to)}
189
+ </Text>
190
+ </Stack>
191
+
192
+ <Grid minItemWidth="180px" gap={4}>
193
+ <Stat label="Distance" value={tour.km} unit="km" />
194
+ <Stat label="Hours at work" value={hours} unit="h" decimals={1} data-callout="2" />
195
+ <Stack gap={2} data-callout="3">
196
+ <Stat label="Stops" value={tour.stops.length} />
197
+ {late > 0 && (
198
+ <Badge tone="warning">
199
+ {late} {late === 1 ? "stop" : "stops"} outside the window
200
+ </Badge>
201
+ )}
202
+ </Stack>
203
+ </Grid>
204
+
205
+ <Grid minItemWidth="320px" gap={4}>
206
+ <Card>
207
+ <CardHeader title="Cost of the tour" />
208
+ <CardBody data-callout="4">
209
+ <Calculation aria-label={`Cost per stop, tour ${tour.id}`}>
210
+ <Quotient label="Cost per stop" unit="€" decimals={2}>
211
+ <Sum label="Cost of the tour" unit="€" decimals={2}>
212
+ <Product label="Energy" unit="€" decimals={2}>
213
+ <Quotient label={electric ? "Kilowatt hours" : "Litres"} unit={electric ? "kWh" : "l"} decimals={1}>
214
+ <Product label="Distance × consumption" unit={electric ? "kWh·km/100 km" : "l·km/100 km"}>
215
+ <Given label="Distance" value={tour.km} unit="km" source="Route planner" />
216
+ <Given
217
+ label="Consumption"
218
+ value={COST_RATES.per100Km[vehicle.type]}
219
+ unit={electric ? "kWh/100 km" : "l/100 km"}
220
+ source="Fleet data"
221
+ />
222
+ </Product>
223
+ <Given label="Per 100 km" value={100} unit="km" />
224
+ </Quotient>
225
+ <Given
226
+ label={electric ? "Electricity price" : "Diesel price"}
227
+ value={electric ? COST_RATES.electricityPerKwh : COST_RATES.dieselPerLitre}
228
+ unit={electric ? "€/kWh" : "€/l"}
229
+ source="Energy contract, March"
230
+ />
231
+ </Product>
232
+ <Product label="Driver" unit="€" decimals={2}>
233
+ <Given label="Hours, loading to return" value={hours} unit="h" source="Tour plan" />
234
+ <Given label="Driver's hour, all in" value={COST_RATES.driverPerHour} unit="€/h" source="Payroll rates 2026" />
235
+ </Product>
236
+ <Given label="Vehicle, per day" value={COST_RATES.vehiclePerDay[vehicle.type]} unit="€" source="Lease and upkeep" />
237
+ </Sum>
238
+ <Given label="Stops" value={tour.stops.length} source="Tour plan" />
239
+ </Quotient>
240
+ </Calculation>
241
+ </CardBody>
242
+ </Card>
243
+
244
+ <Card>
245
+ <CardHeader title="Rates" />
246
+ <CardBody data-callout="5">
247
+ <Stack gap={2}>
248
+ <Text size="sm">
249
+ Diesel {COST_RATES.dieselPerLitre.toFixed(2)} €/l · electricity {COST_RATES.electricityPerKwh.toFixed(2)} €/kWh
250
+ </Text>
251
+ <Text size="sm">
252
+ Per 100 km: van {COST_RATES.per100Km.van} l · truck {COST_RATES.per100Km.truck} l · electric van{" "}
253
+ {COST_RATES.per100Km["e-van"]} kWh
254
+ </Text>
255
+ <Text size="sm">Driver's hour, all in: {COST_RATES.driverPerHour.toFixed(2)} €</Text>
256
+ <Text size="sm">
257
+ Vehicle per day: van {COST_RATES.vehiclePerDay.van} € · truck {COST_RATES.vehiclePerDay.truck} € · electric van{" "}
258
+ {COST_RATES.vehiclePerDay["e-van"]} €
259
+ </Text>
260
+ </Stack>
261
+ </CardBody>
262
+ </Card>
263
+ </Grid>
264
+ </Stack>
265
+ );
266
+ }
267
+ ```
268
+
269
+ It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
270
+
271
+ ### Check an invoice before approving it
272
+
273
+ 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.
274
+
275
+ 1. The invoices waiting for a signature; the header names the supplier, the cost centre and the due date.
276
+ 2. Every line is worked out from quantity, unit price and discount; a discounted line folds open to the share that is paid.
277
+ 3. VAT is charged per rate, on the net of the lines that carry it - never on the gross amount.
278
+ 4. Who has signed already and who is still to sign.
279
+ 5. The decision is taken beside the working, not on a total read somewhere else.
280
+
281
+ Built from: Calculation, Tree, Given, Select, Card, Badge, Button, Alert.
282
+
283
+ ```tsx
284
+ import { useState } from "react";
285
+ import { Alert, Badge, Button, Card, CardBody, CardHeader, FormField, Grid, Select, Stack, Text } from "@umriss-ui/core";
286
+ 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
+
290
+ const WAITING = INVOICES.filter((one) => one.status === "awaiting approval");
291
+ const DECISION_TONE = { approved: "success", rejected: "danger", pending: "neutral" } as const;
292
+ const date = (at: number) => new Date(at).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
293
+ const percent = (rate: number) => `${Math.round(rate * 100)} %`;
294
+
295
+ /** One line's net amount: quantity × unit price, times the share paid after a
296
+ discount. A function and not a component: the calculation reads its
297
+ elements by their type, so a wrapper of one's own would not be recognised. */
298
+ function lineNet(line: InvoiceLine, id: string) {
299
+ return (
300
+ <Product key={id} id={id} label={line.description} unit="€" decimals={2}>
301
+ <Given label="Quantity" value={line.quantity} />
302
+ <Given label="Unit price" value={line.unitPrice} unit="€" decimals={2} />
303
+ {line.discount > 0 && (
304
+ <Difference label="Share paid" format="percent">
305
+ <Given label="List price" value={1} format="percent" />
306
+ <Given label="Discount" value={line.discount} format="percent" source="Framework agreement" />
307
+ </Difference>
308
+ )}
309
+ </Product>
310
+ );
311
+ }
312
+
313
+ export default function CheckAnInvoice() {
314
+ const [invoiceId, setInvoiceId] = useState(WAITING[0]!.id);
315
+ const [decided, setDecided] = useState<Record<string, "approved" | "rejected">>({});
316
+ const invoice = WAITING.find((one) => one.id === invoiceId) ?? WAITING[0]!;
317
+ const centre = COST_CENTRES.find((one) => one.id === invoice.costCentre)!;
318
+ const approvals = APPROVALS.filter((one) => one.invoice === invoice.id);
319
+ const next = approvals.find((one) => one.decision === "pending");
320
+ const decision = decided[invoice.id];
321
+
322
+ const lineIds = invoice.lines.map((_, i) => `line-${i}`);
323
+ const rates = [...new Set(invoice.lines.map((line) => line.vatRate))].filter((rate) => rate > 0);
324
+ /* The net a rate is charged on: the whole net where every line carries it,
325
+ one line on its own, or the sum of the lines that do. */
326
+ const netAt = (rate: number) => {
327
+ const ids = lineIds.filter((_, i) => invoice.lines[i]!.vatRate === rate);
328
+ if (ids.length === lineIds.length) return <Ref to="net" />;
329
+ if (ids.length === 1) return <Ref to={ids[0]!} />;
330
+ return (
331
+ <Sum label={`Net at ${percent(rate)}`} unit="€" decimals={2}>
332
+ {ids.map((id) => (
333
+ <Ref key={id} to={id} />
334
+ ))}
335
+ </Sum>
336
+ );
337
+ };
338
+ const vatAt = (rate: number) => (
339
+ <Product key={rate} label={`VAT ${percent(rate)}`} unit="€" decimals={2}>
340
+ {netAt(rate)}
341
+ <Given label="Rate" value={rate} format="percent" />
342
+ </Product>
343
+ );
344
+
345
+ return (
346
+ <Stack gap={4}>
347
+ <Stack direction="row" gap={4} align="end" wrap data-callout="1">
348
+ <FormField label="Invoice">
349
+ <Select value={invoiceId} onChange={(event) => setInvoiceId(event.target.value)}>
350
+ {WAITING.map((one) => (
351
+ <option key={one.id} value={one.id}>
352
+ {one.id} · {one.supplier}
353
+ </option>
354
+ ))}
355
+ </Select>
356
+ </FormField>
357
+ <Text size="sm" tone="muted">
358
+ {invoice.supplier} · {centre.id} {centre.name} · received {date(invoice.received)} · due {date(invoice.due)}
359
+ </Text>
360
+ </Stack>
361
+
362
+ <Grid minItemWidth="320px" gap={4}>
363
+ <Card>
364
+ <CardHeader title="Amount due" />
365
+ <CardBody>
366
+ <Stack gap={2}>
367
+ <Text size="sm" tone="muted" data-callout="2">
368
+ Each line: quantity × unit price × the share paid after its discount.
369
+ </Text>
370
+ <Calculation aria-label={`Amount due, invoice ${invoice.id}`}>
371
+ <Sum label="Amount due" unit="€" decimals={2}>
372
+ {invoice.lines.length === 1 ? (
373
+ lineNet(invoice.lines[0]!, "net")
374
+ ) : (
375
+ <Sum id="net" label="Net total" unit="€" decimals={2}>
376
+ {invoice.lines.map((line, i) => lineNet(line, lineIds[i]!))}
377
+ </Sum>
378
+ )}
379
+ {rates.length === 1 ? (
380
+ vatAt(rates[0]!)
381
+ ) : (
382
+ <Sum label="VAT" unit="€" decimals={2}>
383
+ {rates.map(vatAt)}
384
+ </Sum>
385
+ )}
386
+ </Sum>
387
+ </Calculation>
388
+ <Text size="sm" tone="muted" data-callout="3">
389
+ VAT {rates.map(percent).join(" and ")}, each on the lines that carry it.
390
+ </Text>
391
+ </Stack>
392
+ </CardBody>
393
+ </Card>
394
+
395
+ <Card>
396
+ <CardHeader title="Approval" />
397
+ <CardBody>
398
+ <Stack gap={4}>
399
+ <Stack gap={2} data-callout="4">
400
+ {approvals.map((one) => (
401
+ <Stack key={one.step} direction="row" gap={3} align="center" wrap>
402
+ <Badge tone={DECISION_TONE[one.decision]}>{one.decision}</Badge>
403
+ <Text size="sm">
404
+ {one.step === "finance" ? "Finance" : "Cost centre"} · {one.approver}
405
+ {one.at !== undefined && ` · ${date(one.at)}`}
406
+ </Text>
407
+ </Stack>
408
+ ))}
409
+ </Stack>
410
+ {decision === undefined ? (
411
+ <Stack direction="row" gap={2} data-callout="5">
412
+ <Button variant="primary" onClick={() => setDecided({ ...decided, [invoice.id]: "approved" })}>
413
+ Approve as {next?.approver ?? "approver"}
414
+ </Button>
415
+ <Button variant="danger" onClick={() => setDecided({ ...decided, [invoice.id]: "rejected" })}>
416
+ Reject
417
+ </Button>
418
+ </Stack>
419
+ ) : (
420
+ <Alert tone={decision === "approved" ? "success" : "danger"} title={`${invoice.id} ${decision}`} data-callout="5">
421
+ {decision === "approved" ? "It moves on to the next signature." : "The supplier is told why."}
422
+ </Alert>
423
+ )}
424
+ </Stack>
425
+ </CardBody>
426
+ </Card>
427
+ </Grid>
428
+ </Stack>
429
+ );
430
+ }
431
+ ```
432
+
433
+ It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
434
+
435
+ ### Explain the shift's OEE
436
+
437
+ A shift lead at Brenholt Tile Works explains at the 10:30 handover why the kiln line's OEE stands where it stands.
438
+
439
+ 1. The tiles give the three counts the OEE is made of, as the line reports them.
440
+ 2. The stop that cost availability, taken from the same readings - so the downtime in the calculation has a cause.
441
+ 3. The OEE worked out as availability × performance × quality, each folded with its formula under its name, and read against the 85 % target.
442
+ 4. Planned time, run time and total count are each used twice; they are defined once and referred to elsewhere.
443
+
444
+ Built from: Calculation, Tree, Given, Stat, Card, Alert.
445
+
446
+ ```tsx
447
+ import { Alert, Card, CardBody, CardHeader, Grid, Stack, Stat, Text } from "@umriss-ui/core";
448
+ 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
+
475
+ {stopped.length > 0 && (
476
+ <Alert tone="warning" title={`Feed stopped ${clock(stopped[0]!)} to ${clock(stopped.at(-1)! + 1)}`} data-callout="2">
477
+ The kiln's zone 3 went above {KILN.alarm} °C; tiles fired outside {KILN.tolerance[0]} to {KILN.tolerance[1]} °C
478
+ count as scrap.
479
+ </Alert>
480
+ )}
481
+
482
+ <Card>
483
+ <CardHeader title={`OEE, early shift up to ${clock(MINUTE)}`} />
484
+ <CardBody>
485
+ <Stack gap={2}>
486
+ <div data-callout="3">
487
+ <Calculation aria-label="OEE of the early shift so far">
488
+ <Product label="OEE" format="percent" target={OEE_TARGET} limits={OEE_LIMITS}>
489
+ <Quotient label="Availability" format="percent" explanation="Lost to stops of the belt.">
490
+ <Difference id="runtime" label="Run time" unit="min">
491
+ <Given id="planned" label="Planned production time" value={count.planned} unit="min" source="Shift calendar" />
492
+ <Given label="Downtime" value={count.downtime} unit="min" source="Belt drive" />
493
+ </Difference>
494
+ <Ref to="planned" />
495
+ </Quotient>
496
+ <Quotient label="Performance" format="percent" explanation="Lost to jams and slow cycles.">
497
+ <Product label="Ideal run time" unit="min">
498
+ <Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/pc" source="Kiln K1 rating" />
499
+ <Given id="total" label="Total count" value={count.total} unit="pcs" source="Exit counter" />
500
+ </Product>
501
+ <Ref to="runtime" />
502
+ </Quotient>
503
+ <Quotient label="Quality" format="percent" explanation="Lost to scrap fired outside the tolerance.">
504
+ <Given label="Good count" value={count.good} unit="pcs" source="Exit counter" />
505
+ <Ref to="total" />
506
+ </Quotient>
507
+ </Product>
508
+ </Calculation>
509
+ </div>
510
+ <Text size="sm" tone="muted" data-callout="4">
511
+ Hover a line: its operands and every place it is used light up.
512
+ </Text>
513
+ </Stack>
514
+ </CardBody>
515
+ </Card>
516
+ </Stack>
517
+ );
518
+ }
519
+ ```
520
+
521
+ It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
522
+
523
+ ## Getting started
524
+
525
+ What a calculation is for, and what an application sets up before the first one.
526
+
527
+ ### Installation
528
+
529
+ Show a figure together with how it came about (a derivation, a statement of account): the operator before each number, the result above a double rule, and every figure opening into the numbers it came from.
530
+
531
+ ```ts
532
+ import { Calculation, Given, Difference, Quotient, Ref } from "@umriss-ui/calculation";
533
+ ```
534
+
535
+ Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/installation
536
+
537
+ Install with `pnpm add @umriss-ui/calculation @umriss-ui/core`; core is a peer dependency and brings formats, wording and the assessment of targets and limits. React 18 or 19 as a peer as well.
538
+
539
+ The stylesheet loads itself: the package imports it and marks CSS as a side effect, so a bundler keeps it. `@umriss-ui/calculation/styles.css` stays exported for setups that link stylesheets by hand.
540
+
541
+ You write the figure as elements and hand in only the givens; the calculation performs every operation it shows, so the screen cannot disagree with the number. Start with [Calculation](#/calculation).
542
+
543
+ #### Examples
544
+
545
+ ##### The availability of a service
546
+
547
+ Write the figure as elements; the calculation computes every number on the screen and assesses the result against its `target`.
548
+
549
+ ```tsx
550
+ import { Calculation, Difference, Given, Quotient, Ref } from "@umriss-ui/calculation";
551
+
552
+ export default function Availability() {
553
+ return (
554
+ <Calculation aria-label="Availability of Checkout, March so far">
555
+ <Quotient label="Availability, Checkout" format="percent" decimals={2} target={0.9995}>
556
+ <Difference label="Minutes up" unit="min">
557
+ <Given id="month" label="Minutes this month" value={23670} unit="min" />
558
+ <Given label="Minutes down" value={34} unit="min" />
559
+ </Difference>
560
+ <Ref to="month" />
561
+ </Quotient>
562
+ </Calculation>
563
+ );
564
+ }
565
+ ```
6
566
 
7
567
  ## Writing a calculation
8
568
 
@@ -10,7 +570,7 @@ Two forms written as elements: a tree for a figure put together from factors, a
10
570
 
11
571
  ### Calculation
12
572
 
13
- How a figure came about, as a statement down to the numbers it came from - evaluated by the library, never handed in.
573
+ Lets a reader follow and redo a figure: each line a quantity with its operator, derived lines folded until opened beneath themselves. Reach for it where a number on the screen gets questioned: a cost, an availability, an invoice total.
14
574
 
15
575
  ```ts
16
576
  import { Calculation, Given, Sum, Product, Quotient, Ref, Chain, Plus, Interim } from "@umriss-ui/calculation";
@@ -18,63 +578,63 @@ import { Calculation, Given, Sum, Product, Quotient, Ref, Chain, Plus, Interim }
18
578
 
19
579
  Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/calculation
20
580
 
581
+ There is no prop for a result. Every derived number is computed with the operator its line names, at full precision; only the screen rounds.
582
+
583
+ The nesting is the fold structure and the child order is the operand order. What TypeScript cannot check – a `Ref` to nothing, a circle through references, a wrong operand count, a duplicate id, a component of your own wrapping `Given` – fails on the first render with a message saying which (ADR-0027).
584
+
585
+ Every line is read as one sentence by a screen reader, and every derivation is a disclosure: its button says whether it shows or hides how the line is derived.
586
+
21
587
  #### Examples
22
588
 
23
589
  ##### Two numbers and a sum
24
590
 
591
+ Two givens and the operator that joins them; the result stands beneath its operands, above a double rule.
592
+
25
593
  ```tsx
26
594
  import { Calculation, Given, Sum } from "@umriss-ui/calculation";
27
595
 
28
- /* The smallest calculation there is: two givens and the operator that joins
29
- them. The result stands beneath its operands under a rule, and the double
30
- rule says that this is where the calculation ends. Nothing here is typed in
31
- twice - the 1,000 is computed by the calculation, never handed to it. */
32
596
  export default function FirstSum() {
33
597
  return (
34
- <Calculation aria-label="Output of press 3, Tuesday">
35
- <Sum label="Output, Tuesday" unit="pcs">
36
- <Given label="Early shift" value={512} unit="pcs" />
37
- <Given label="Late shift" value={488} unit="pcs" />
598
+ <Calculation aria-label="Parcels out of North depot, Tuesday">
599
+ <Sum label="Parcels out, North depot" unit="parcels">
600
+ <Given label="Morning tours" value={512} unit="parcels" />
601
+ <Given label="Afternoon tours" value={488} unit="parcels" />
38
602
  </Sum>
39
603
  </Calculation>
40
604
  );
41
605
  }
42
606
  ```
43
607
 
44
- ##### OEE of an early shift
608
+ ##### Write a tree
609
+
610
+ Nest operators for a figure made of factors: each folds with its formula under its name, and `Ref` stands for a quantity used twice.
45
611
 
46
612
  ```tsx
613
+ import { countAt, IDEAL_CYCLE_MINUTES, plant, SHIFT_MINUTES } from "./plant";
47
614
  import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
48
615
 
49
- /* A tree: overall equipment effectiveness as availability × performance ×
50
- quality, each derived from the shift's numbers. The three factors stand
51
- above the result, each folded, with the formula it hides beneath its name;
52
- click a name to open it.
616
+ const COUNT = countAt(plant(17), SHIFT_MINUTES);
53
617
 
54
- Planned production time, run time and total count are each used twice. They
55
- are defined once, where they belong, and stand elsewhere as a `<Ref>` - a
56
- reference shows the quantity's name and number, never its derivation again.
57
- Hover a line and its operands and every place it is used light up. */
58
618
  export default function Oee() {
59
619
  return (
60
- <Calculation aria-label="OEE, early shift">
620
+ <Calculation aria-label="OEE of the kiln line, early shift">
61
621
  <Product label="OEE" format="percent" target={0.85}>
62
622
  <Quotient label="Availability" format="percent">
63
623
  <Difference id="runtime" label="Run time" unit="min">
64
- <Given id="planned" label="Planned production time" value={450} unit="min" />
65
- <Given label="Downtime" value={38} unit="min" />
624
+ <Given id="planned" label="Planned production time" value={COUNT.planned} unit="min" />
625
+ <Given label="Downtime" value={COUNT.downtime} unit="min" />
66
626
  </Difference>
67
627
  <Ref to="planned" />
68
628
  </Quotient>
69
629
  <Quotient label="Performance" format="percent">
70
630
  <Product label="Ideal run time" unit="min">
71
- <Given label="Ideal cycle time" value={0.8} unit="min/pc" />
72
- <Given id="total" label="Total count" value={480} unit="pcs" />
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" />
73
633
  </Product>
74
634
  <Ref to="runtime" />
75
635
  </Quotient>
76
636
  <Quotient label="Quality" format="percent">
77
- <Given label="Good count" value={461} unit="pcs" />
637
+ <Given label="Good tiles" value={COUNT.good} unit="tiles" />
78
638
  <Ref to="total" />
79
639
  </Quotient>
80
640
  </Product>
@@ -83,55 +643,62 @@ export default function Oee() {
83
643
  }
84
644
  ```
85
645
 
86
- ##### A costing sheet
646
+ It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
647
+
648
+ ##### Write a chain
649
+
650
+ Put lines in a `Chain` for a sheet read top to bottom; each works into the value before it, and an `Interim` names that value.
87
651
 
88
652
  ```tsx
89
- import { Calculation, Chain, Given, Interim, Plus, Product, Ref, Times } from "@umriss-ui/calculation";
90
-
91
- /* The same calculation read the other way: as a chain, top to bottom, the way
92
- a costing sheet stands on paper. Each line works its operand into the value
93
- before it, and an `<Interim>` names that value where it stands.
94
-
95
- The chain stands open, as on paper: lines, a rule, the interim, and on.
96
- A surcharge on an earlier line is a `<Plus>` holding a product with a
97
- `<Ref>` - the overhead is 120 % of the direct labour, not of the running
98
- value - and that product is a tree: it folds, showing the formula it hides,
99
- and opens beneath its line on a click. */
100
- export default function CostingSheet() {
653
+ import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref } from "@umriss-ui/calculation";
654
+
655
+ export default function Invoice() {
101
656
  return (
102
- <Calculation aria-label="Offer price, order A-2041">
657
+ <Calculation aria-label="Invoice INV-26-0318, Brandlow Office Supply">
103
658
  <Chain>
104
- <Given id="material" label="Direct material" value={1840} unit="€" decimals={2} />
105
- <Plus>
106
- <Product label="Material overhead" unit="€" decimals={2}>
107
- <Given label="Material overhead rate" value={0.12} format="percent" />
108
- <Ref to="material" />
659
+ <Product id="lamps" label="Desk lamps, LED" unit="€" decimals={2}>
660
+ <Given label="Lamps" value={24} unit="pcs" />
661
+ <Given label="Price per lamp" value={48.9} unit="€/pc" decimals={2} />
662
+ </Product>
663
+ <Minus>
664
+ <Product label="Discount" unit="€" decimals={2}>
665
+ <Given label="Discount on lamps" value={0.1} format="percent" />
666
+ <Ref to="lamps" />
109
667
  </Product>
110
- </Plus>
111
- <Interim label="Material cost" unit="€" decimals={2} />
112
- <Plus id="labour" label="Direct labour" value={960} unit="€" decimals={2} />
668
+ </Minus>
113
669
  <Plus>
114
- <Product label="Production overhead" unit="€" decimals={2}>
115
- <Given label="Production overhead rate" value={1.2} format="percent" />
116
- <Ref to="labour" />
670
+ <Product label="Printer paper" unit="€" decimals={2}>
671
+ <Given label="Boxes" value={40} unit="boxes" />
672
+ <Given label="Price per box" value={21.5} unit="€/box" decimals={2} />
117
673
  </Product>
118
674
  </Plus>
119
- <Interim id="production" label="Production cost" unit="€" decimals={2} />
675
+ <Plus label="Delivery" value={35} unit="€" decimals={2} />
676
+ <Interim id="net" label="Net amount" unit="€" decimals={2} />
120
677
  <Plus>
121
- <Product label="Administration and sales overhead" unit="€" decimals={2}>
122
- <Given label="Overhead rate" value={0.15} format="percent" />
123
- <Ref to="production" />
678
+ <Product label="VAT" unit="€" decimals={2}>
679
+ <Given label="VAT rate" value={0.19} format="percent" />
680
+ <Ref to="net" />
124
681
  </Product>
125
682
  </Plus>
126
- <Interim label="Cost price" unit="€" decimals={2} />
127
- <Times label="Profit mark-up" value={1.08} />
128
- <Interim label="Net offer price" unit="€" decimals={2} target={5000} />
683
+ <Interim label="Invoice total" unit="€" decimals={2} />
129
684
  </Chain>
130
685
  </Calculation>
131
686
  );
132
687
  }
133
688
  ```
134
689
 
690
+ #### When to use something else
691
+
692
+ - A single figure with its verdict, without the working → `Stat` from @umriss-ui/core
693
+ - Many figures side by side, summed per group → `Table` with grouping from @umriss-ui/table
694
+
695
+ #### Keyboard
696
+
697
+ | Key | Action |
698
+ |---|---|
699
+ | `Tab` | Moves to the next line that can be opened. |
700
+ | `Enter or Space` | Opens or closes the derivation beneath the focused line. |
701
+
135
702
  #### API
136
703
 
137
704
  ##### `CalculationProps`
@@ -142,19 +709,16 @@ export default function CostingSheet() {
142
709
 
143
710
  Also takes every attribute of the rendered element – without `children`.
144
711
 
145
- #### Why it is like this
712
+ #### Known limits
146
713
 
147
- ##### The library computes what it shows
714
+ - No result, formula text or operator of your own: a written-out operation the library did not perform could say something the number does not (ADR-0027).
715
+ - No input: a calculation shows how a figure came about; changing a given is your application's form.
148
716
 
149
- There is no prop for a result. Every number on a derived line is computed with the operator the line names, by the calculation itself - so a page cannot say "412 ÷ 450" beside a figure that was computed from something else. That is also why there is no formula text and no operator of your own: a written-out operation the library did not perform could say something the number does not.
150
-
151
- ##### Written as it is shown
152
-
153
- The nesting is the fold structure and the child order is the operand order. A quantity used twice is defined once, where you place it, and stands everywhere else as a `<Ref>`. What TypeScript cannot check - a reference to nothing, a circle through references, a wrapper component the calculation cannot look into - fails on the first render with a message saying which. See `docs/adr/0027-a-calculation-is-written-as-it-is-shown.md`.
717
+ What umriss deliberately does not build, and why: [ADR-0032](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0032-what-umriss-is-not.md).
154
718
 
155
719
  ### Tree
156
720
 
157
- Sum, difference, product and quotient, each holding its operands - and a quantity used twice, defined once.
721
+ Sum, difference, product and quotient, each holding its operands: the form for a figure put together from factors, folded to its formula until a reader opens it.
158
722
 
159
723
  ```ts
160
724
  import { Sum, Difference, Product, Quotient, Ref } from "@umriss-ui/calculation";
@@ -162,119 +726,114 @@ import { Sum, Difference, Product, Quotient, Ref } from "@umriss-ui/calculation"
162
726
 
163
727
  Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/tree
164
728
 
729
+ `Sum`, `Product` and `Difference` (a − b − c) take two or more operands, `Quotient` exactly two. A quantity used twice is defined once with an `id` and stands elsewhere as a `Ref`, which shows its name and number but never its derivation again.
730
+
165
731
  #### Examples
166
732
 
167
- ##### A share with a target
733
+ ##### Show a share against a target
734
+
735
+ `format="percent"` shows a ratio as per cent while it stays a ratio in every operation; a `target` is missed, never violated.
168
736
 
169
737
  ```tsx
170
738
  import { Calculation, Given, Quotient } from "@umriss-ui/calculation";
171
739
 
172
- /* `format="percent"` presents a plain ratio as per cent - the quotient stays
173
- 0.9604 in every operation. The target is assessed through core's
174
- `assess()` and stands beneath the number: missed, never violated. */
175
740
  export default function ShareWithTarget() {
176
741
  return (
177
- <Calculation aria-label="First-pass yield, press 3">
178
- <Quotient label="First-pass yield" format="percent" target={0.95}>
179
- <Given label="Good parts" value={461} unit="pcs" />
180
- <Given label="Parts made" value={480} unit="pcs" />
742
+ <Calculation aria-label="Utilisation of the Web team, week 12">
743
+ <Quotient label="Utilisation, Web team" format="percent" target={0.85}>
744
+ <Given label="Hours booked on projects" value={141} unit="h" />
745
+ <Given label="Hours the team can be planned for" value={176} unit="h" />
181
746
  </Quotient>
182
747
  </Calculation>
183
748
  );
184
749
  }
185
750
  ```
186
751
 
187
- ##### A difference of three
752
+ ##### Take several amounts away
753
+
754
+ `Difference` takes every operand after the first away from it, each with its minus in the operator column.
188
755
 
189
756
  ```tsx
190
757
  import { Calculation, Difference, Given } from "@umriss-ui/calculation";
191
758
 
192
- /* `<Difference>` takes the first operand and subtracts every other: a − b − c.
193
- Each operand after the first carries its minus in the operator column. */
194
759
  export default function DifferenceOfThree() {
195
760
  return (
196
- <Calculation aria-label="Planned production time, early shift">
197
- <Difference label="Planned production time" unit="min">
198
- <Given label="Shift length" value={480} unit="min" />
199
- <Given label="Breaks" value={30} unit="min" />
200
- <Given label="Planned maintenance" value={20} unit="min" />
761
+ <Calculation aria-label="Plannable hours, Chloe Durand, week 12">
762
+ <Difference label="Plannable hours, Chloe" unit="h">
763
+ <Given label="Capacity" value={40} unit="h" />
764
+ <Given label="Leave" value={8} unit="h" />
765
+ <Given label="Meetings" value={5} unit="h" />
766
+ <Given label="Support rota" value={4} unit="h" />
201
767
  </Difference>
202
768
  </Calculation>
203
769
  );
204
770
  }
205
771
  ```
206
772
 
207
- ##### A product with units
773
+ ##### Multiply quantities with units
774
+
775
+ A `unit` is a label you write, never converted or checked; `decimals` fixes the places shown while every operation uses the full value.
208
776
 
209
777
  ```tsx
210
778
  import { Calculation, Given, Product } from "@umriss-ui/calculation";
211
779
 
212
- /* A unit is a label the caller writes; nothing converts or checks it. The
213
- product of kW and h is written as kWh because the caller says so - the
214
- calculation does no unit algebra, and does not pretend to. `decimals` fixes
215
- the places shown; the operation uses the full value. */
216
780
  export default function ProductWithUnits() {
217
781
  return (
218
- <Calculation aria-label="Energy of the furnace, one shift">
219
- <Product label="Energy, one shift" unit="kWh" decimals={1}>
220
- <Given label="Mean power" value={86.35} unit="kW" />
221
- <Given label="Hours at temperature" value={7.25} unit="h" />
782
+ <Calculation aria-label="Diesel cost of tour T-01">
783
+ <Product label="Diesel, tour T-01" unit="€" decimals={2}>
784
+ <Given label="Distance" value={87} unit="km" />
785
+ <Given label="Consumption" value={0.095} unit="l/km" />
786
+ <Given label="Diesel price" value={1.68} unit="€/l" decimals={2} />
222
787
  </Product>
223
788
  </Calculation>
224
789
  );
225
790
  }
226
791
  ```
227
792
 
228
- ##### A quantity used twice
793
+ ##### Use a quantity twice
794
+
795
+ Give a quantity an `id` where it first belongs and write `Ref` wherever it is used again; hover either place and both light up.
229
796
 
230
797
  ```tsx
231
- import { Calculation, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
798
+ import { Calculation, Difference, Given, Quotient, Ref } from "@umriss-ui/calculation";
232
799
 
233
- /* The material per part enters both products. It is defined once, where it
234
- first belongs, and stands as a `<Ref>` in the second place: a reference
235
- shows the quantity's name and number, in italics, never its derivation
236
- again. Open both products and hover either place - both light up. */
237
800
  export default function UsedTwice() {
238
801
  return (
239
- <Calculation aria-label="Material lost to scrap, week 12">
240
- <Quotient label="Material lost to scrap" format="percent" decimals={2}>
241
- <Product label="Scrap cost" unit="€" decimals={2}>
242
- <Given label="Scrapped parts" value={38} unit="pcs" />
243
- <Given id="material" label="Material per part" value={2.8} unit="€" decimals={2} />
244
- </Product>
245
- <Product label="Material cost" unit="€" decimals={2}>
246
- <Given label="Parts made" value={1278} unit="pcs" />
247
- <Ref to="material" />
248
- </Product>
802
+ <Calculation aria-label="Overspend of Marketing, February">
803
+ <Quotient label="Overspend, share of budget" format="percent">
804
+ <Difference label="Overspend" unit="€">
805
+ <Given label="Actual, February" value={77420} unit="€" />
806
+ <Given id="budget" label="Budget, February" value={68000} unit="€" />
807
+ </Difference>
808
+ <Ref to="budget" />
249
809
  </Quotient>
250
810
  </Calculation>
251
811
  );
252
812
  }
253
813
  ```
254
814
 
255
- ##### First-pass yield across three stations
815
+ ##### Build operands from data
256
816
 
257
- ```tsx
258
- import { Calculation, Given, Product, Quotient } from "@umriss-ui/calculation";
817
+ `.map` inside an operator works; folded, a line with more than four operands says how many it holds instead of a long formula.
259
818
 
260
- /* A product of three quotients, one per station, each built from the
261
- station's own counts - the rolled throughput yield. Every factor stands
262
- folded with its formula beneath it; open one to redo it. */
819
+ ```tsx
820
+ import { DOWNTIME_MINUTES, MONTH_MINUTES, SERVICES } from "./operations";
821
+ import { Calculation, Difference, Given, Product, Quotient } from "@umriss-ui/calculation";
263
822
 
264
- const STATIONS = [
265
- { name: "Turning", good: 982, made: 1000 },
266
- { name: "Grinding", good: 961, made: 982 },
267
- { name: "Final inspection", good: 948, made: 961 },
268
- ];
823
+ /* The services a booking passes through, in order. */
824
+ const PATH = ["sign-in", "search", "checkout", "billing", "notifications"];
269
825
 
270
- export default function FirstPassYield() {
826
+ export default function FromData() {
271
827
  return (
272
- <Calculation aria-label="Rolled throughput yield, shaft line">
273
- <Product label="Rolled throughput yield" format="percent" target={0.95}>
274
- {STATIONS.map((station) => (
275
- <Quotient key={station.name} label={`Yield, ${station.name.toLowerCase()}`} format="percent">
276
- <Given label={`Good, ${station.name.toLowerCase()}`} value={station.good} unit="pcs" />
277
- <Given label={`Into ${station.name.toLowerCase()}`} value={station.made} unit="pcs" />
828
+ <Calculation aria-label="Availability of a booking, March so far">
829
+ <Product label="Availability of a booking" format="percent" decimals={2}>
830
+ {SERVICES.filter((service) => PATH.includes(service.id)).map((service) => (
831
+ <Quotient key={service.id} label={`Availability, ${service.name}`} format="percent" decimals={2}>
832
+ <Difference label={`Minutes up, ${service.name}`} unit="min">
833
+ <Given label="Minutes this month" value={MONTH_MINUTES} unit="min" />
834
+ <Given label={`Minutes down, ${service.name}`} value={DOWNTIME_MINUTES[service.id]} unit="min" />
835
+ </Difference>
836
+ <Given label="Minutes this month" value={MONTH_MINUTES} unit="min" />
278
837
  </Quotient>
279
838
  ))}
280
839
  </Product>
@@ -283,35 +842,43 @@ export default function FirstPassYield() {
283
842
  }
284
843
  ```
285
844
 
286
- ##### Energy per piece, with a limit
845
+ It imports `operations.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
846
+
847
+ ##### Hold a figure to its limits
848
+
849
+ `limits` are assessed through core's `assess()`; the verdict stands as a word beneath the number, never as a colour alone.
287
850
 
288
851
  ```tsx
289
852
  import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
290
853
 
291
- /* Three meters summed and divided by the good pieces. The limit is assessed
292
- through core's `assess()`: the verdict stands as a word beneath the number,
293
- never as a colour alone. */
294
- export default function EnergyPerPiece() {
854
+ export default function Limits() {
295
855
  return (
296
- <Calculation aria-label="Energy per good piece, hall 2">
856
+ <Calculation aria-label="Cost per stop, tour T-03">
297
857
  <Quotient
298
- label="Energy per good piece"
299
- unit="kWh/pc"
300
- decimals={3}
301
- limits={[{ value: 0.45, side: "upper", severity: "warning" }]}
858
+ label="Cost per stop, tour T-03"
859
+ unit="€/stop"
860
+ decimals={2}
861
+ limits={[
862
+ { value: 60, side: "upper", severity: "warning" },
863
+ { value: 100, side: "upper", severity: "alarm" },
864
+ ]}
302
865
  >
303
- <Sum label="Energy, hall 2" unit="kWh">
304
- <Given label="Presses" value={1840} unit="kWh" />
305
- <Given label="Compressed air" value={612} unit="kWh" />
306
- <Given label="Lighting and HVAC" value={295} unit="kWh" />
866
+ <Sum label="Cost of tour T-03" unit="€" decimals={2}>
867
+ <Given label="Driver" value={216.2} unit="€" decimals={2} />
868
+ <Given label="Diesel" value={71.37} unit="€" decimals={2} />
869
+ <Given label="Truck, one day" value={145} unit="€" decimals={2} />
307
870
  </Sum>
308
- <Given label="Good pieces" value={5920} unit="pcs" />
871
+ <Given label="Stops" value={5} unit="stops" />
309
872
  </Quotient>
310
873
  </Calculation>
311
874
  );
312
875
  }
313
876
  ```
314
877
 
878
+ #### When to use something else
879
+
880
+ - A sheet read top to bottom, line by line → Chain
881
+
315
882
  #### API
316
883
 
317
884
  ##### `OperatorProps`
@@ -335,9 +902,16 @@ export default function EnergyPerPiece() {
335
902
  |---|---|---|---|
336
903
  | `to` (required) | `string` | — | The `id` of the quantity this reference stands for. |
337
904
 
905
+ #### Known limits
906
+
907
+ - Four operators and nothing else – no powers, roots or functions; compute those before and hand them in as givens.
908
+ - A unit is a label and is never converted or checked; the calculation does no unit algebra.
909
+
910
+ What umriss deliberately does not build, and why: [ADR-0032](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0032-what-umriss-is-not.md).
911
+
338
912
  ### Chain
339
913
 
340
- A sheet read top to bottom: each line worked into the value before it, and interims naming the value where they stand.
914
+ A sheet read top to bottom (a running calculation): each line worked into the value before it, and interims naming the value where they stand. Reach for it for invoices, budgets and cost sheets.
341
915
 
342
916
  ```ts
343
917
  import { Chain, Plus, Minus, Times, DividedBy, Interim } from "@umriss-ui/calculation";
@@ -345,21 +919,88 @@ import { Chain, Plus, Minus, Times, DividedBy, Interim } from "@umriss-ui/calcul
345
919
 
346
920
  Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/chain
347
921
 
922
+ A chain has no precedence: every line works into the value before it, strictly in order. So `Times` and `DividedBy` stand alone between two named values; anything else fails on the first render.
923
+
924
+ A chain in view stands open – it is the working itself. What should show only on request goes into one line as a tree, which folds. Only an `Interim` shows the running value (ADR-0028).
925
+
348
926
  #### Examples
349
927
 
350
- ##### A payslip
928
+ ##### Take amounts away from a first quantity
929
+
930
+ A chain starts with a quantity without operator; `Minus` and `Plus` take a label and a value directly, and an `Interim` ends it.
351
931
 
352
932
  ```tsx
353
- import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref, Sum } from "@umriss-ui/calculation";
933
+ import { Calculation, Chain, Given, Interim, Minus } from "@umriss-ui/calculation";
934
+
935
+ export default function NetWeight() {
936
+ return (
937
+ <Calculation aria-label="Net weight, shipment FP-1004223">
938
+ <Chain>
939
+ <Given label="Gross weight" value={1250} unit="kg" decimals={1} />
940
+ <Minus label="Pallet" value={25} unit="kg" decimals={1} />
941
+ <Minus label="Crates" value={144} unit="kg" decimals={1} />
942
+ <Minus label="Packaging" value={11.5} unit="kg" decimals={1} />
943
+ <Interim label="Net weight" unit="kg" decimals={1} />
944
+ </Chain>
945
+ </Calculation>
946
+ );
947
+ }
948
+ ```
949
+
950
+ ##### Mix additions and deductions
951
+
952
+ Where a figure is a list of items, write each as a line of its own; a credit is a `Minus` where it stands.
953
+
954
+ ```tsx
955
+ import { Calculation, Chain, Given, Interim, Minus, Plus } from "@umriss-ui/calculation";
956
+
957
+ export default function TourCost() {
958
+ return (
959
+ <Calculation aria-label="Cost of tour T-01, Tuesday">
960
+ <Chain>
961
+ <Given label="Driver, 6.5 h" value={224.25} unit="€" decimals={2} />
962
+ <Plus label="Diesel" value={13.89} unit="€" decimals={2} />
963
+ <Plus label="Van, one day" value={58} unit="€" decimals={2} />
964
+ <Plus label="Tolls" value={9.4} unit="€" decimals={2} />
965
+ <Plus label="Parking" value={6} unit="€" decimals={2} />
966
+ <Minus label="Fuel card rebate" value={0.7} unit="€" decimals={2} />
967
+ <Interim label="Cost of the tour" unit="€" decimals={2} />
968
+ </Chain>
969
+ </Calculation>
970
+ );
971
+ }
972
+ ```
973
+
974
+ ##### Multiply between two interims
975
+
976
+ A chain has no precedence, so `Times` and `DividedBy` stand alone between two interims; after a `Plus` they fail on the first render.
977
+
978
+ ```tsx
979
+ import { Calculation, Chain, Given, Interim, Plus, Times } from "@umriss-ui/calculation";
980
+
981
+ export default function MarkUpAndVat() {
982
+ return (
983
+ <Calculation aria-label="Price of a workshop day">
984
+ <Chain>
985
+ <Given label="Two facilitators, one day" value={960} unit="€" decimals={2} />
986
+ <Plus label="Room and materials" value={180} unit="€" decimals={2} />
987
+ <Interim label="Cost of the day" unit="€" decimals={2} />
988
+ <Times label="Mark-up" value={1.25} />
989
+ <Interim label="Net price" unit="€" decimals={2} />
990
+ <Times label="VAT factor" value={1.19} />
991
+ <Interim label="Gross price" unit="€" decimals={2} />
992
+ </Chain>
993
+ </Calculation>
994
+ );
995
+ }
996
+ ```
997
+
998
+ ##### Fold a group of lines into one
354
999
 
355
- /* A chain as a payslip reads: the gross salary, each deduction on a line of
356
- its own with its minus, and the net salary only once they all stand. A
357
- chain in view never folds - it is the working itself.
1000
+ A chain in view never folds; to show items only on request, put them into one line as a tree: a `Sum` held by a `Minus`.
358
1001
 
359
- What a reader should see only on request goes into ONE line as a tree: the
360
- four social security contributions are a `<Sum>` held by a `<Minus>`. The
361
- line shows their total and the formula it hides; a click opens the four
362
- beneath it, each computed from its rate and the gross salary. */
1002
+ ```tsx
1003
+ import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref, Sum } from "@umriss-ui/calculation";
363
1004
 
364
1005
  const CONTRIBUTIONS = [
365
1006
  { name: "Pension insurance", rate: 0.093 },
@@ -395,181 +1036,97 @@ export default function Payslip() {
395
1036
  }
396
1037
  ```
397
1038
 
398
- ##### Net weight
399
-
400
- ```tsx
401
- import { Calculation, Chain, Given, Interim, Minus } from "@umriss-ui/calculation";
402
-
403
- /* The simplest chain: a first quantity with no operator, lines that take
404
- something away, and an `<Interim>` that names the value. `<Minus>` and
405
- `<Plus>` take a label and a value directly - a given written in place. */
406
- export default function NetWeight() {
407
- return (
408
- <Calculation aria-label="Net weight, delivery 4471">
409
- <Chain>
410
- <Given label="Gross weight" value={1250} unit="kg" decimals={1} />
411
- <Minus label="Pallet" value={25} unit="kg" decimals={1} />
412
- <Minus label="Crates" value={144} unit="kg" decimals={1} />
413
- <Minus label="Packaging" value={11.5} unit="kg" decimals={1} />
414
- <Interim label="Net weight" unit="kg" decimals={1} />
415
- </Chain>
416
- </Calculation>
417
- );
418
- }
419
- ```
420
-
421
- ##### The cost of a shift
422
-
423
- ```tsx
424
- import { Calculation, Chain, Given, Interim, Minus, Plus } from "@umriss-ui/calculation";
425
-
426
- /* A flat chain of cost items, plus and minus mixed: a credit for energy fed
427
- back is taken away where it stands. Written as a tree this would be one
428
- `<Sum>` whose line repeats all seven names; as a chain every item is a line
429
- of its own, and the interim is the sum a reader looks for. */
430
- export default function ShiftCost() {
431
- return (
432
- <Calculation aria-label="Cost of the early shift, line 2">
433
- <Chain>
434
- <Given label="Operators" value={1152} unit="€" decimals={2} />
435
- <Plus label="Shift supervisor" value={264} unit="€" decimals={2} />
436
- <Plus label="Energy" value={418.4} unit="€" decimals={2} />
437
- <Plus label="Compressed air" value={96.2} unit="€" decimals={2} />
438
- <Plus label="Tooling wear" value={185} unit="€" decimals={2} />
439
- <Plus label="Cleaning" value={48} unit="€" decimals={2} />
440
- <Minus label="Energy fed back" value={37.6} unit="€" decimals={2} />
441
- <Interim label="Cost of the shift" unit="€" decimals={2} />
442
- </Chain>
443
- </Calculation>
444
- );
445
- }
446
- ```
1039
+ ##### Build the lines from data
447
1040
 
448
- ##### Fifteen items from data
1041
+ `.map` inside a chain makes each item a `Plus`, and all stand in view above their interim.
449
1042
 
450
1043
  ```tsx
451
1044
  import { Calculation, Chain, Given, Interim, Plus } from "@umriss-ui/calculation";
452
1045
 
453
- /* `.map` over data inside a chain: each cost item becomes a `<Plus>`, and all
454
- fifteen stand in view above their interim. Where only the total should show
455
- until a reader asks, the items go into one line as a `<Sum>` instead - as
456
- the payslip does with its contributions; folded, such a line says how many
457
- operands it holds rather than a formula of fifteen terms. */
458
-
459
1046
  const ITEMS = [
460
- ["Steel sheet", 412.8],
461
- ["Tube 40×2", 186.4],
462
- ["Flat bar", 94.1],
463
- ["Screws M8", 22.6],
464
- ["Nuts M8", 9.8],
465
- ["Washers", 4.2],
466
- ["Hinges", 61.5],
467
- ["Gas springs", 88],
468
- ["Seals", 17.3],
469
- ["Powder coating", 143.9],
470
- ["Primer", 38.5],
471
- ["Labels", 6.4],
472
- ["Packaging", 29.7],
473
- ["Pallet", 14],
474
- ["Freight", 120],
1047
+ ["Compute, booking API", 4128.4],
1048
+ ["Compute, search", 2864.1],
1049
+ ["Compute, workers", 1741.9],
1050
+ ["Database, primary", 2310],
1051
+ ["Database, replicas", 1540],
1052
+ ["Object storage", 386.2],
1053
+ ["Block storage", 512.8],
1054
+ ["Data transfer out", 948.6],
1055
+ ["Load balancers", 264],
1056
+ ["Queues", 118.3],
1057
+ ["Cache cluster", 690],
1058
+ ["Log storage", 431.7],
1059
+ ["Metrics and traces", 822.5],
1060
+ ["Backups", 207.4],
1061
+ ["Support plan", 1200],
475
1062
  ] as const;
476
1063
 
477
1064
  export default function FifteenItems() {
478
1065
  const [[firstName, firstAmount], ...rest] = ITEMS;
479
1066
  return (
480
- <Calculation aria-label="Bought-in cost, order A-2052">
1067
+ <Calculation aria-label="Cloud bill, March">
481
1068
  <Chain>
482
1069
  <Given label={firstName} value={firstAmount} unit="€" decimals={2} />
483
1070
  {rest.map(([name, amount]) => (
484
1071
  <Plus key={name} label={name} value={amount} unit="€" decimals={2} />
485
1072
  ))}
486
- <Interim label="Bought-in cost" unit="€" decimals={2} />
1073
+ <Interim label="Cloud bill, March" unit="€" decimals={2} />
487
1074
  </Chain>
488
1075
  </Calculation>
489
1076
  );
490
1077
  }
491
1078
  ```
492
1079
 
493
- ##### Mark-up and VAT
494
-
495
- ```tsx
496
- import { Calculation, Chain, Given, Interim, Plus, Times } from "@umriss-ui/calculation";
497
-
498
- /* A chain has no precedence: every line works into the value before it. So a
499
- `<Times>` stands alone between two named values - directly after an
500
- interim and directly before the next. Writing it after a `<Plus>` fails on
501
- the first render; nobody multiplies a number they have not seen. */
502
- export default function MarkUpAndVat() {
503
- return (
504
- <Calculation aria-label="Price of a spare part">
505
- <Chain>
506
- <Given label="Cost price" value={148.2} unit="€" decimals={2} />
507
- <Plus label="Handling" value={12} unit="€" decimals={2} />
508
- <Interim label="Cost with handling" unit="€" decimals={2} />
509
- <Times label="Mark-up" value={1.25} />
510
- <Interim label="Net price" unit="€" decimals={2} />
511
- <Times label="VAT factor" value={1.19} />
512
- <Interim label="Gross price" unit="€" decimals={2} />
513
- </Chain>
514
- </Calculation>
515
- );
516
- }
517
- ```
1080
+ ##### Put a chain inside a tree
518
1081
 
519
- ##### A chain inside a tree
1082
+ As an operand a chain folds and stands for its last interim; opened, it stands whole beneath its line.
520
1083
 
521
1084
  ```tsx
522
1085
  import { Calculation, Chain, Given, Interim, Minus, Plus, Quotient, Ref } from "@umriss-ui/calculation";
523
1086
 
524
- /* The two forms mix. Here a quotient's numerator is a chain: the net output of
525
- a line, from what it made to what could be shipped. As an operand the chain
526
- folds and stands for its last interim; opened, it stands whole beneath that
527
- line, indented, every interim in view. The denominator is the chain's own
528
- first line, referred to by its id. */
529
1087
  export default function ChainInATree() {
530
1088
  return (
531
- <Calculation aria-label="Shipping yield, line 4">
532
- <Quotient label="Shipping yield" format="percent" target={0.97}>
1089
+ <Calculation aria-label="Delivery rate, North depot, Tuesday">
1090
+ <Quotient label="Delivery rate" format="percent" target={0.97}>
533
1091
  <Chain>
534
- <Given id="made" label="Parts made" value={2400} unit="pcs" />
535
- <Minus label="Scrap" value={41} unit="pcs" />
536
- <Minus label="Held for inspection" value={26} unit="pcs" />
537
- <Plus label="Released after rework" value={18} unit="pcs" />
538
- <Interim label="Parts shipped" unit="pcs" />
1092
+ <Given id="out" label="Parcels out" value={1000} unit="parcels" />
1093
+ <Minus label="Failed attempts" value={31} unit="parcels" />
1094
+ <Minus label="Refused at the door" value={6} unit="parcels" />
1095
+ <Plus label="Delivered on a second run" value={14} unit="parcels" />
1096
+ <Interim label="Parcels delivered" unit="parcels" />
539
1097
  </Chain>
540
- <Ref to="made" />
1098
+ <Ref to="out" />
541
1099
  </Quotient>
542
1100
  </Calculation>
543
1101
  );
544
1102
  }
545
1103
  ```
546
1104
 
547
- ##### An energy balance with limits
1105
+ ##### Hold an interim to its limits
1106
+
1107
+ An `Interim` takes `target` and `limits` like any quantity; here the error budget left is assessed against two lower limits.
548
1108
 
549
1109
  ```tsx
550
- import { Calculation, Chain, Given, Interim, Minus, Plus } from "@umriss-ui/calculation";
1110
+ import { Calculation, Chain, Given, Interim, Minus, Product } from "@umriss-ui/calculation";
551
1111
 
552
- /* Interims take targets and limits like any quantity. The balance of a hall:
553
- what came in, what went out, and what is left unaccounted for - a number
554
- that should be small. Its limit is assessed; the verdict stands beneath it
555
- as a word. */
556
- export default function EnergyBalance() {
1112
+ export default function ErrorBudget() {
557
1113
  return (
558
- <Calculation aria-label="Energy balance, hall 2, week 12">
1114
+ <Calculation aria-label="Error budget of Checkout, March">
559
1115
  <Chain>
560
- <Given label="Grid supply" value={48200} unit="kWh" />
561
- <Plus label="Photovoltaics" value={6150} unit="kWh" />
562
- <Interim label="Energy in" unit="kWh" />
563
- <Minus label="Presses" value={31480} unit="kWh" />
564
- <Minus label="Compressed air" value={9820} unit="kWh" />
565
- <Minus label="Heating and ventilation" value={8870} unit="kWh" />
566
- <Minus label="Lighting" value={2210} unit="kWh" />
1116
+ <Product label="Error budget, March" unit="min" decimals={1}>
1117
+ <Given label="Minutes in March" value={44640} unit="min" />
1118
+ <Given label="Allowed downtime" value={0.0005} format="percent" decimals={2} />
1119
+ </Product>
1120
+ <Minus label="INC-1039, payment provider timeouts" value={9} unit="min" decimals={1} />
1121
+ <Minus label="INC-1036, failed deploy" value={4} unit="min" decimals={1} />
1122
+ <Minus label="INC-1048, card payments time out" value={21} unit="min" decimals={1} />
567
1123
  <Interim
568
- label="Unaccounted for"
569
- unit="kWh"
1124
+ label="Budget left"
1125
+ unit="min"
1126
+ decimals={1}
570
1127
  limits={[
571
- { value: 1500, side: "upper", severity: "warning" },
572
- { value: 3000, side: "upper", severity: "alarm" },
1128
+ { value: 5, side: "lower", severity: "warning" },
1129
+ { value: 0, side: "lower", severity: "alarm" },
573
1130
  ]}
574
1131
  />
575
1132
  </Chain>
@@ -578,6 +1135,10 @@ export default function EnergyBalance() {
578
1135
  }
579
1136
  ```
580
1137
 
1138
+ #### When to use something else
1139
+
1140
+ - A figure made of factors, folded to its formula → Tree
1141
+
581
1142
  #### API
582
1143
 
583
1144
  ##### `ChainProps`
@@ -619,23 +1180,15 @@ export default function EnergyBalance() {
619
1180
  | `explanation` | `string` | — | A sentence of plain text beside the line: why the quantity is here, or what it means. |
620
1181
  | `aside` | `ReactNode` | — | Content of the caller's own beside the line - a sparkline, a link. It never takes part in the calculation. |
621
1182
 
622
- #### Why it is like this
623
-
624
- ##### No precedence, and a times alone
1183
+ #### Known limits
625
1184
 
626
- A chain works every line into the value before it, strictly in order - the way a costing sheet is read. There is no precedence, so `a + b × c` would mean `(a + b) × c` here and `a + (b × c)` in a reader's head. Rather than explain that, the chain makes it unwritable: a `<Times>` or `<DividedBy>` stands alone between two named values, and anything else fails on the first render. Nobody multiplies a number they have not seen.
1185
+ - A chain in view does not fold; as an operand of a tree it folds to its last interim.
627
1186
 
628
- ##### A chain does not fold
629
-
630
- A chain is the working itself - folded to its interims it would only repeat the numbers and hide the steps it exists to show. So a chain in view stands open, and what a reader should see only on request goes into one line as a tree: a `<Sum>` of contributions held by a `<Minus>` folds like any tree, and opens beneath its line. Whoever writes the chain decides, line by line, what is always in view.
631
-
632
- ##### Only an interim shows the running value
633
-
634
- A line shows its own number. A running value without a name is a number nobody can quote; where you want to see it, set an `<Interim>`. See `docs/adr/0028-a-calculation-is-a-tree-or-a-chain.md`.
1187
+ What umriss deliberately does not build, and why: [ADR-0032](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0032-what-umriss-is-not.md).
635
1188
 
636
1189
  ### Given
637
1190
 
638
- The numbers a calculation starts from: where they came from, how old they are, and the language they are read in.
1191
+ The numbers a calculation starts from (inputs, leaves): where they came from, when they were true, and whether they are still fresh.
639
1192
 
640
1193
  ```ts
641
1194
  import { Given } from "@umriss-ui/calculation";
@@ -643,87 +1196,84 @@ import { Given } from "@umriss-ui/calculation";
643
1196
 
644
1197
  Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/given
645
1198
 
1199
+ A given with `asOf` and `ages` carries a freshness through core, as a `Stat` does – beside the number, never instead of its verdict. There are no default ages.
1200
+
1201
+ The operator words, the reasons for absence and the number formats are core's wording; German comes from `@umriss-ui/core/wording/de` (ADR-0019).
1202
+
646
1203
  #### Examples
647
1204
 
648
- ##### Where a number came from
1205
+ ##### Say where a number came from
1206
+
1207
+ `source`, `asOf` and `ages` give a given its origin and freshness; `explanation` and `aside` stand beside a line without taking part.
649
1208
 
650
1209
  ```tsx
651
1210
  import { useState } from "react";
652
1211
  import { Sparkline } from "@umriss-ui/core";
653
1212
  import { Calculation, Given, Quotient } from "@umriss-ui/calculation";
654
1213
 
655
- /* A given can say where it came from and when it was true. With `ages` it
656
- carries a freshness, as a Stat does: fresh, stale or lost - beside the
657
- number, never instead of its verdict. `explanation` is a sentence of plain
658
- text, and `aside` holds content of your own - here the last shifts'
659
- scrap as a sparkline. Neither takes part in the calculation. */
660
-
661
1214
  const AGES = { stale: 15 * 60_000, lost: 60 * 60_000 };
662
1215
 
663
1216
  export default function WhereItCameFrom() {
664
1217
  const [now] = useState(() => Date.now());
665
1218
  return (
666
- <Calculation aria-label="Scrap rate, press 3">
1219
+ <Calculation aria-label="Error rate of Checkout, last five minutes">
667
1220
  <Quotient
668
- label="Scrap rate"
1221
+ label="Error rate, Checkout"
669
1222
  format="percent"
670
- limits={[{ value: 0.03, side: "upper", severity: "warning" }]}
671
- explanation="Scrapped parts over every part the press made."
672
- aside={<Sparkline data={[0.021, 0.024, 0.019, 0.028, 0.033, 0.036]} aria-hidden="true" />}
1223
+ decimals={2}
1224
+ limits={[{ value: 0.01, side: "upper", severity: "warning" }]}
1225
+ explanation="Failed requests over every request in the last five minutes."
1226
+ aside={<Sparkline data={[0.0012, 0.0015, 0.0011, 0.0019, 0.0264, 0.0287]} aria-hidden="true" />}
673
1227
  >
674
- <Given label="Scrapped parts" value={18} unit="pcs" source="Quality station QS-2" asOf={now - 4 * 60_000} ages={AGES} />
675
- <Given label="Parts made" value={512} unit="pcs" source="Press counter" asOf={now - 22 * 60_000} ages={AGES} />
1228
+ <Given label="Failed requests" value={118} unit="requests" source="Load balancer logs" asOf={now - 4 * 60_000} ages={AGES} />
1229
+ <Given label="Requests" value={4390} unit="requests" source="Request counter" asOf={now - 22 * 60_000} ages={AGES} />
676
1230
  </Quotient>
677
1231
  </Calculation>
678
1232
  );
679
1233
  }
680
1234
  ```
681
1235
 
682
- ##### An as-of time without ages
1236
+ ##### State when a number was true
1237
+
1238
+ `asOf` alone shows the time a number held; there are no default `ages`, because stale means different things on different screens.
683
1239
 
684
1240
  ```tsx
685
1241
  import { Calculation, Given, Product } from "@umriss-ui/calculation";
686
1242
 
687
- /* `asOf` alone states when the number was true, as a time. With `ages` as
688
- well, the given carries a freshness through core, as a `Stat` does - the
689
- library has no default ages, because "stale" at a furnace means something
690
- else than on a monthly report. */
691
1243
  export default function AsOf() {
692
1244
  return (
693
- <Calculation aria-label="Value of the stock, bar steel">
694
- <Product label="Value of the stock" unit="€" decimals={2}>
695
- <Given label="Bar steel in stock" value={18.4} unit="t" source="Stocktaking" asOf={new Date(2026, 2, 13, 16, 0)} />
696
- <Given label="Price" value={812} unit="€/t" source="Price list 2026-Q1" asOf={new Date(2026, 0, 2, 8, 0)} />
1245
+ <Calculation aria-label="Licence cost, design tool, March">
1246
+ <Product label="Licence cost, March" unit="€" decimals={2}>
1247
+ <Given label="Seats in use" value={12} unit="seats" source="Admin console" asOf={new Date(2026, 2, 13, 16, 0)} />
1248
+ <Given label="Price per seat" value={45} unit="€/seat" decimals={2} source="Contract with Nimbrel Software" asOf={new Date(2026, 0, 2, 8, 0)} />
697
1249
  </Product>
698
1250
  </Calculation>
699
1251
  );
700
1252
  }
701
1253
  ```
702
1254
 
703
- ##### In German
1255
+ ##### Read in German
1256
+
1257
+ Operator words, reasons and number formats come from core's `LanguageProvider` (ADR-0019); your labels stay as you write them.
704
1258
 
705
1259
  ```tsx
706
1260
  import { LanguageProvider } from "@umriss-ui/core";
707
1261
  import { GERMAN_FORMATS, GERMAN_WORDING } from "@umriss-ui/core/wording/de";
708
- import { Calculation, Chain, Given, Interim, Plus, Product, Ref } from "@umriss-ui/calculation";
1262
+ import { Calculation, Chain, Given, Interim, Minus, Product, Ref } from "@umriss-ui/calculation";
709
1263
 
710
- /* The operator words, the reasons for absence, the approximation note and the
711
- disclosure labels are core's wording, English by default and German behind
712
- the subpath (ADR-0019). The numbers follow the formats: 1.840,00 €. The
713
- labels are yours and stay as you write them. */
714
1264
  export default function InGerman() {
715
1265
  return (
716
1266
  <LanguageProvider wording={GERMAN_WORDING} formats={GERMAN_FORMATS}>
717
- <Calculation aria-label="Materialkosten, Auftrag A-2041">
1267
+ <Calculation aria-label="Rechnung INV-26-0317, Messestand">
718
1268
  <Chain>
719
- <Given id="mek" label="Materialeinzelkosten" value={1840} unit="€" decimals={2} />
720
- <Plus>
721
- <Product label="Materialgemeinkosten" unit="€" decimals={2}>
722
- <Given label="Zuschlagssatz" value={0.12} format="percent" />
723
- <Ref to="mek" />
1269
+ <Given id="stand" label="Messestand, 3 Tage" value={12400} unit="€" decimals={2} />
1270
+ <Minus>
1271
+ <Product label="Rabatt" unit="€" decimals={2}>
1272
+ <Given label="Rabattsatz" value={0.05} format="percent" />
1273
+ <Ref to="stand" />
724
1274
  </Product>
725
- </Plus>
726
- <Interim label="Materialkosten" unit="€" decimals={2} />
1275
+ </Minus>
1276
+ <Interim label="Nettobetrag" unit="€" decimals={2} />
727
1277
  </Chain>
728
1278
  </Calculation>
729
1279
  </LanguageProvider>
@@ -753,11 +1303,11 @@ export default function InGerman() {
753
1303
 
754
1304
  ## In practice
755
1305
 
756
- What a calculation does when the numbers are not what they should be, and two figures worked in full.
1306
+ What a calculation does when the numbers are not what they should be, and whole figures worked in full.
757
1307
 
758
1308
  ### What can go wrong
759
1309
 
760
- A missing number, a division by zero, rounded figures that do not add up, and an alarm folded out of sight.
1310
+ A number not there yet, a division by zero, rounded figures that do not add up, and a limit crossed inside a folded line: what a calculation shows in each case.
761
1311
 
762
1312
  ```ts
763
1313
  import { Calculation } from "@umriss-ui/calculation";
@@ -765,40 +1315,36 @@ import { Calculation } from "@umriss-ui/calculation";
765
1315
 
766
1316
  Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/what-can-go-wrong
767
1317
 
1318
+ An absent given is never zero: every quantity that depends on it is absent too, with the reason, up to the result. A quotient by zero is absent with its own reason.
1319
+
768
1320
  #### Examples
769
1321
 
770
- ##### A missing number
1322
+ ##### A number that is not there yet
1323
+
1324
+ A given with `value={null}` shows as missing, and so does every figure that depends on it – never carried on as zero.
771
1325
 
772
1326
  ```tsx
773
- import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
1327
+ import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
1328
+
1329
+ const BOOKED = [
1330
+ { name: "Luis Moreno", hours: 34 },
1331
+ { name: "Hana Sato", hours: 38 },
1332
+ { name: "Kofi Mensah", hours: null },
1333
+ { name: "Freya Olsen", hours: 36 },
1334
+ { name: "David Kowalski", hours: 17 },
1335
+ ];
774
1336
 
775
- /* The downtime has not been booked yet. It shows as missing - and so does
776
- every figure that depends on it, each with the reason, up to the result.
777
- Nothing is carried on as zero: an OEE computed with no downtime would be a
778
- number, and a wrong one. */
779
1337
  export default function Missing() {
780
1338
  return (
781
- <Calculation aria-label="OEE, late shift">
782
- <Product label="OEE" format="percent" target={0.85}>
783
- <Quotient label="Availability" format="percent">
784
- <Difference id="runtime" label="Run time" unit="min">
785
- <Given id="planned" label="Planned production time" value={450} unit="min" />
786
- <Given label="Downtime" value={null} unit="min" />
787
- </Difference>
788
- <Ref to="planned" />
789
- </Quotient>
790
- <Quotient label="Performance" format="percent">
791
- <Product label="Ideal run time" unit="min">
792
- <Given label="Ideal cycle time" value={0.8} unit="min/pc" />
793
- <Given id="total" label="Total count" value={455} unit="pcs" />
794
- </Product>
795
- <Ref to="runtime" />
796
- </Quotient>
797
- <Quotient label="Quality" format="percent">
798
- <Given label="Good count" value={441} unit="pcs" />
799
- <Ref to="total" />
800
- </Quotient>
801
- </Product>
1339
+ <Calculation aria-label="Utilisation of the Apps team, week 11">
1340
+ <Quotient label="Utilisation, Apps team" format="percent" target={0.85}>
1341
+ <Sum label="Hours booked" unit="h">
1342
+ {BOOKED.map((person) => (
1343
+ <Given key={person.name} label={person.name} value={person.hours} unit="h" source="Timesheets" />
1344
+ ))}
1345
+ </Sum>
1346
+ <Given label="Capacity, Apps team" value={172} unit="h" />
1347
+ </Quotient>
802
1348
  </Calculation>
803
1349
  );
804
1350
  }
@@ -806,50 +1352,46 @@ export default function Missing() {
806
1352
 
807
1353
  ##### A division by zero
808
1354
 
1355
+ A tour that delivered nothing has no cost per parcel – not zero, not infinity; the quotient is absent with its own reason.
1356
+
809
1357
  ```tsx
810
- import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
1358
+ import { Calculation, Given, Product, Quotient, Sum } from "@umriss-ui/calculation";
811
1359
 
812
- /* A machine that did not run has no output per hour - not zero, and not
813
- infinity. The quotient is absent with its own reason, and so is everything
814
- that depends on it. */
815
1360
  export default function DivisionByZero() {
816
1361
  return (
817
- <Calculation aria-label="Output per hour, cell 7">
818
- <Sum label="Output per hour, cell 7" unit="pcs/h">
819
- <Quotient label="Robot A" unit="pcs/h">
820
- <Given label="Parts, robot A" value={412} unit="pcs" />
821
- <Given label="Hours run, robot A" value={7.5} unit="h" />
822
- </Quotient>
823
- <Quotient label="Robot B" unit="pcs/h">
824
- <Given label="Parts, robot B" value={0} unit="pcs" />
825
- <Given label="Hours run, robot B" value={0} unit="h" />
1362
+ <Calculation aria-label="Planned cost of tomorrow's parcels, tour T-07">
1363
+ <Product label="Cost of tomorrow's 80 parcels" unit="€" decimals={2}>
1364
+ <Quotient label="Cost per parcel, tour T-07" unit="€/parcel" decimals={2}>
1365
+ <Sum label="Cost of tour T-07" unit="€" decimals={2}>
1366
+ <Given label="Driver, standby" value={69} unit="€" decimals={2} />
1367
+ <Given label="E-van, one day" value={72} unit="€" decimals={2} />
1368
+ </Sum>
1369
+ <Given label="Parcels delivered" value={0} unit="parcels" />
826
1370
  </Quotient>
827
- </Sum>
1371
+ <Given label="Parcels planned for tomorrow" value={80} unit="parcels" />
1372
+ </Product>
828
1373
  </Calculation>
829
1374
  );
830
1375
  }
831
1376
  ```
832
1377
 
833
- ##### When the rounded figures do not add up
1378
+ ##### Rounded figures that do not add up
1379
+
1380
+ Operations use full values and only the screen rounds; where the rounded operands miss the rounded result, the line says so with ≈.
834
1381
 
835
1382
  ```tsx
836
- import { Calculation, Given, Product, Quotient, Sum } from "@umriss-ui/calculation";
1383
+ import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
1384
+
1385
+ const PEOPLE = ["Arjun Mehta", "Chloe Durand", "Eva Novak"];
837
1386
 
838
- /* Every operation uses the full value; rounding happens only on the screen.
839
- Three shares of 33.3 % each add up to 100 % - not to the 99.9 % a reader
840
- gets from the rounded figures. The line says so with "≈" and a note,
841
- instead of leaving the reader to find it. */
842
1387
  export default function Rounding() {
843
1388
  return (
844
- <Calculation aria-label="Shares of the shift">
845
- <Sum label="All three lines" format="percent">
846
- {["Line 1", "Line 2", "Line 3"].map((line) => (
847
- <Quotient key={line} label={`Share of ${line}`} format="percent">
848
- <Product label={`Hours on ${line}`} unit="h">
849
- <Given label="Machines" value={2} />
850
- <Given label="Hours each" value={4} unit="h" />
851
- </Product>
852
- <Given label="Hours in the shift" value={24} unit="h" />
1389
+ <Calculation aria-label="Shares of the booking app release">
1390
+ <Sum label="All three" format="percent">
1391
+ {PEOPLE.map((person) => (
1392
+ <Quotient key={person} label={`Share, ${person}`} format="percent">
1393
+ <Given label={`Hours, ${person}`} value={12} unit="h" />
1394
+ <Given label="Hours on the release" value={36} unit="h" />
853
1395
  </Quotient>
854
1396
  ))}
855
1397
  </Sum>
@@ -860,29 +1402,31 @@ export default function Rounding() {
860
1402
 
861
1403
  ##### A limit violated deep inside
862
1404
 
1405
+ A folded line holding a worse verdict says so quietly – without taking the colour itself, since its own number is not the one in alarm.
1406
+
863
1407
  ```tsx
864
1408
  import { Calculation, Chain, Given, Interim, Plus, Product } from "@umriss-ui/calculation";
865
1409
 
866
- /* The scrap rate on line 2 is beyond its alarm limit, inside a folded line.
867
- The folded line says so quietly - "Inside: Alarm limit exceeded" - without
868
- taking on the alarm's colour itself: its own number is not the one in
869
- alarm. Open it to find the line that is. */
1410
+ const ERROR_LIMITS = [
1411
+ { value: 0.01, side: "upper" as const, severity: "warning" as const },
1412
+ { value: 0.02, side: "upper" as const, severity: "alarm" as const },
1413
+ ];
1414
+
870
1415
  export default function LimitInside() {
871
- const scrapLimits = [{ value: 0.03, side: "upper" as const, severity: "alarm" as const }];
872
1416
  return (
873
- <Calculation aria-label="Scrap cost, hall 1">
1417
+ <Calculation aria-label="Payments at risk, this morning">
874
1418
  <Chain>
875
- <Product label="Scrap cost, line 1" unit="€" decimals={2}>
876
- <Given label="Scrap rate, line 1" value={0.018} format="percent" limits={scrapLimits} />
877
- <Given label="Material, line 1" value={24800} unit="€" decimals={2} />
1419
+ <Product label="At risk, Billing" unit="€" decimals={2}>
1420
+ <Given label="Error rate, Billing" value={0.004} format="percent" limits={ERROR_LIMITS} />
1421
+ <Given label="Payments, Billing" value={24800} unit="€" decimals={2} />
878
1422
  </Product>
879
1423
  <Plus>
880
- <Product label="Scrap cost, line 2" unit="€" decimals={2}>
881
- <Given label="Scrap rate, line 2" value={0.041} format="percent" limits={scrapLimits} />
882
- <Given label="Material, line 2" value={19650} unit="€" decimals={2} />
1424
+ <Product label="At risk, Checkout" unit="€" decimals={2}>
1425
+ <Given label="Error rate, Checkout" value={0.026} format="percent" limits={ERROR_LIMITS} />
1426
+ <Given label="Payments, Checkout" value={19650} unit="€" decimals={2} />
883
1427
  </Product>
884
1428
  </Plus>
885
- <Interim label="Scrap cost, hall 1" unit="€" decimals={2} />
1429
+ <Interim label="Payments at risk" unit="€" decimals={2} />
886
1430
  </Chain>
887
1431
  </Calculation>
888
1432
  );
@@ -891,7 +1435,7 @@ export default function LimitInside() {
891
1435
 
892
1436
  ### Worked examples
893
1437
 
894
- The cost per piece of a production order and the OEE of a hall - some fifty quantities each, from data.
1438
+ Whole figures as a screen shows them: the cost per stop of a day's tours, invoices with discount and VAT, and the equipment effectiveness of a hall, each built from data.
895
1439
 
896
1440
  ```ts
897
1441
  import { Calculation, Chain, Given, Ref } from "@umriss-ui/calculation";
@@ -901,125 +1445,178 @@ Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/worked-exampl
901
1445
 
902
1446
  #### Examples
903
1447
 
904
- ##### The cost per piece of a production order
1448
+ ##### The cost per stop of a day's tours
1449
+
1450
+ Eight tours from data, some sixty quantities: the sheet stands open, every tree in it folded, and the stale diesel price says so.
905
1451
 
906
1452
  ```tsx
907
- import { Calculation, Chain, Given, Interim, Plus, Product, Quotient, Ref, Sum, Times } from "@umriss-ui/calculation";
908
-
909
- /* Everything at once, as an order is actually costed. The bill of material
910
- and the routing come from data; each becomes a tree of its own - quantity
911
- times price, minutes times the machine's rate per minute - and the two
912
- trees are lines of the costing sheet. Setup is spread over the lot size,
913
- the scrap surcharge and the overheads refer back to named interims, and the
914
- sheet ends in a net and a gross price.
915
-
916
- The scrap rate is three days old: its line says so, beside the number, and
917
- keeps its verdict. Some fifty quantities: the sheet itself stands open, a
918
- dozen lines, and every tree in it waits folded until a reader asks. */
919
-
920
- const BILL_OF_MATERIAL = [
921
- { part: "Steel sheet 2 mm", quantity: 1.84, unit: "kg", price: 1.35 },
922
- { part: "Tube 40×2", quantity: 0.62, unit: "m", price: 4.1 },
923
- { part: "Hinge", quantity: 2, unit: "pcs", price: 1.9 },
924
- { part: "Gas spring", quantity: 1, unit: "pcs", price: 6.4 },
925
- { part: "Screw M8×20", quantity: 8, unit: "pcs", price: 0.07 },
926
- { part: "Powder, RAL 7016", quantity: 0.21, unit: "kg", price: 9.8 },
927
- ];
1453
+ import { COST_RATES, TOURS, VEHICLES } from "./logistics";
1454
+ import { Calculation, Chain, DividedBy, Given, Interim, Plus, Product, Sum } from "@umriss-ui/calculation";
928
1455
 
929
- const ROUTING = [
930
- { station: "Laser cutting", minutes: 2.4, rate: 96 },
931
- { station: "Bending", minutes: 1.8, rate: 72 },
932
- { station: "Welding", minutes: 4.2, rate: 84 },
933
- { station: "Powder coating", minutes: 1.5, rate: 60 },
934
- { station: "Assembly", minutes: 3.0, rate: 54 },
935
- ];
1456
+ const typeOf = (vehicleId: string) => VEHICLES.find((vehicle) => vehicle.id === vehicleId)!.type;
1457
+ const hours = (tour: (typeof TOURS)[number]) => (tour.to - tour.from) / 3_600_000 + tour.loading / 60;
1458
+ const DIESEL = TOURS.filter((tour) => typeOf(tour.vehicle) !== "e-van");
1459
+ const ELECTRIC = TOURS.filter((tour) => typeOf(tour.vehicle) === "e-van");
1460
+ const STOPS = TOURS.reduce((sum, tour) => sum + tour.stops.length, 0);
936
1461
 
937
1462
  const AGES = { stale: 24 * 3_600_000, lost: 14 * 24 * 3_600_000 };
938
1463
 
939
- export default function CostPerPiece() {
1464
+ export default function CostPerTour() {
940
1465
  return (
941
- <Calculation aria-label="Cost per piece, order A-2041, lot of 250">
1466
+ <Calculation aria-label="Cost per stop, all tours, Tuesday">
942
1467
  <Chain>
943
- <Sum id="material" label="Direct material" unit="€" decimals={2}>
944
- {BILL_OF_MATERIAL.map((line) => (
945
- <Product key={line.part} label={line.part} unit="€" decimals={2}>
946
- <Given label={`Quantity, ${line.part}`} value={line.quantity} unit={line.unit} />
947
- <Given label={`Price, ${line.part}`} value={line.price} unit={`€/${line.unit}`} />
948
- </Product>
949
- ))}
950
- </Sum>
951
- <Plus>
952
- <Product label="Material overhead" unit="€" decimals={2}>
953
- <Given label="Material overhead rate" value={0.12} format="percent" source="Cost centre accounting 2026" />
954
- <Ref to="material" />
955
- </Product>
956
- </Plus>
957
- <Interim label="Material cost" unit="€" decimals={2} />
958
- <Plus>
959
- <Sum label="Machine time" unit="€" decimals={2}>
960
- {ROUTING.map((step) => (
961
- <Product key={step.station} label={step.station} unit="€" decimals={2}>
962
- <Given label={`Minutes, ${step.station.toLowerCase()}`} value={step.minutes} unit="min" decimals={1} />
963
- <Quotient label={`Rate per minute, ${step.station.toLowerCase()}`} unit="€/min" decimals={3}>
964
- <Given label={`Machine hour rate, ${step.station.toLowerCase()}`} value={step.rate} unit="€/h" />
965
- <Given label="Minutes per hour" value={60} unit="min/h" />
966
- </Quotient>
967
- </Product>
1468
+ <Product label="Drivers" unit="€" decimals={2}>
1469
+ <Sum label="Driver hours" unit="h" decimals={1}>
1470
+ {TOURS.map((tour) => (
1471
+ <Given key={tour.id} label={`Hours, ${tour.id}`} value={hours(tour)} unit="h" decimals={1} />
968
1472
  ))}
969
1473
  </Sum>
970
- </Plus>
971
- <Plus>
972
- <Quotient label="Setup per piece" unit="€" decimals={2}>
973
- <Product label="Setup cost" unit="€" decimals={2}>
974
- <Given label="Setup time" value={1.5} unit="h" />
975
- <Given label="Setup rate" value={78} unit="€/h" />
976
- </Product>
977
- <Given label="Lot size" value={250} unit="pcs" />
978
- </Quotient>
979
- </Plus>
980
- <Interim id="production" label="Production cost" unit="€" decimals={2} />
1474
+ <Given label="Driver rate, all in" value={COST_RATES.driverPerHour} unit="€/h" decimals={2} />
1475
+ </Product>
981
1476
  <Plus>
982
- <Product label="Scrap surcharge" unit="€" decimals={2}>
1477
+ <Product label="Diesel" unit="€" decimals={2}>
1478
+ <Sum label="Diesel used" unit="l" decimals={1}>
1479
+ {DIESEL.map((tour) => (
1480
+ <Product key={tour.id} label={`Diesel, ${tour.id}`} unit="l" decimals={1}>
1481
+ <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
1482
+ <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km[typeOf(tour.vehicle)] / 100} unit="l/km" />
1483
+ </Product>
1484
+ ))}
1485
+ </Sum>
983
1486
  <Given
984
- label="Scrap rate"
985
- value={0.032}
986
- format="percent"
987
- source="MES, last 30 days"
1487
+ label="Diesel price"
1488
+ value={COST_RATES.dieselPerLitre}
1489
+ unit="€/l"
1490
+ decimals={2}
1491
+ source="Fuel card, weekly price"
988
1492
  asOf={new Date(2026, 2, 14, 6, 0)}
989
1493
  ages={AGES}
990
1494
  />
991
- <Ref to="production" />
992
1495
  </Product>
993
1496
  </Plus>
994
1497
  <Plus>
995
- <Product label="Administration and sales overhead" unit="€" decimals={2}>
996
- <Given label="Overhead rate" value={0.14} format="percent" source="Cost centre accounting 2026" />
997
- <Ref to="production" />
1498
+ <Product label="Electricity" unit="€" decimals={2}>
1499
+ <Sum label="Electricity used" unit="kWh" decimals={1}>
1500
+ {ELECTRIC.map((tour) => (
1501
+ <Product key={tour.id} label={`Electricity, ${tour.id}`} unit="kWh" decimals={1}>
1502
+ <Given label={`Distance, ${tour.id}`} value={tour.km} unit="km" />
1503
+ <Given label={`Consumption, ${tour.id}`} value={COST_RATES.per100Km["e-van"] / 100} unit="kWh/km" />
1504
+ </Product>
1505
+ ))}
1506
+ </Sum>
1507
+ <Given label="Electricity price" value={COST_RATES.electricityPerKwh} unit="€/kWh" decimals={2} source="Depot tariff 2026" />
998
1508
  </Product>
999
1509
  </Plus>
1000
- <Interim label="Cost price" unit="€" decimals={2} />
1001
- <Times label="Profit mark-up" value={1.1} decimals={2} />
1002
- <Interim label="Net price per piece" unit="€" decimals={2} target={85} />
1003
- <Times label="VAT factor" value={1.19} />
1004
- <Interim label="Gross price per piece" unit="€" decimals={2} />
1510
+ <Plus>
1511
+ <Sum label="Vehicle days" unit="€" decimals={2}>
1512
+ {TOURS.map((tour) => (
1513
+ <Given key={tour.id} label={`Vehicle day, ${tour.id}`} value={COST_RATES.vehiclePerDay[typeOf(tour.vehicle)]} unit="€" decimals={2} />
1514
+ ))}
1515
+ </Sum>
1516
+ </Plus>
1517
+ <Interim label="Cost of the day's tours" unit="€" decimals={2} />
1518
+ <DividedBy label="Stops" value={STOPS} unit="stops" />
1519
+ <Interim
1520
+ label="Cost per stop"
1521
+ unit="€/stop"
1522
+ decimals={2}
1523
+ limits={[{ value: 30, side: "upper", severity: "warning" }]}
1524
+ />
1005
1525
  </Chain>
1006
1526
  </Calculation>
1007
1527
  );
1008
1528
  }
1009
1529
  ```
1010
1530
 
1531
+ It imports `logistics.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1532
+
1533
+ ##### Invoices with discount and VAT
1534
+
1535
+ Each invoice is a chain from its lines, discounts and VAT by rate; as operands of one sum they fold to their totals.
1536
+
1537
+ ```tsx
1538
+ import type { ReactElement } from "react";
1539
+ import { INVOICES } from "./controlling";
1540
+ import type { Invoice } from "./controlling";
1541
+ import { Calculation, Chain, Difference, Given, Interim, Plus, Product, Ref, Sum } from "@umriss-ui/calculation";
1542
+
1543
+ const OPEN = INVOICES.filter((invoice) => invoice.status === "awaiting approval" || invoice.status === "approved");
1544
+ const percent = (rate: number) => `${Math.round(rate * 100)} %`;
1545
+
1546
+ /* A sum needs two operands; one stands for itself. */
1547
+ const sumOf = (label: string, operands: ReactElement[]) =>
1548
+ operands.length === 1 ? operands[0] : (
1549
+ <Sum label={label} unit="€" decimals={2}>
1550
+ {operands}
1551
+ </Sum>
1552
+ );
1553
+
1554
+ function invoiceChain(invoice: Invoice) {
1555
+ const lineId = (index: number) => `${invoice.id}-line-${index}`;
1556
+ const rates = [...new Set(invoice.lines.map((line) => line.vatRate))];
1557
+ return (
1558
+ <Chain key={invoice.id}>
1559
+ {sumOf(
1560
+ `Net, ${invoice.id}`,
1561
+ invoice.lines.map((line, index) => {
1562
+ const amount = (
1563
+ <Product key={index} id={line.discount === 0 ? lineId(index) : `${lineId(index)}-list`} label={line.description} unit="€" decimals={2}>
1564
+ <Given label={`Quantity, ${line.description}`} value={line.quantity} />
1565
+ <Given label={`Unit price, ${line.description}`} value={line.unitPrice} unit="€" decimals={2} />
1566
+ </Product>
1567
+ );
1568
+ if (line.discount === 0) return amount;
1569
+ return (
1570
+ <Difference key={index} id={lineId(index)} label={`${line.description}, after discount`} unit="€" decimals={2}>
1571
+ {amount}
1572
+ <Product label={`Discount, ${line.description}`} unit="€" decimals={2}>
1573
+ <Given label={`Discount rate, ${line.description}`} value={line.discount} format="percent" />
1574
+ <Ref to={`${lineId(index)}-list`} />
1575
+ </Product>
1576
+ </Difference>
1577
+ );
1578
+ }),
1579
+ )}
1580
+ <Plus>
1581
+ {sumOf(
1582
+ `VAT, ${invoice.id}`,
1583
+ rates.map((rate) => {
1584
+ const lines = invoice.lines.flatMap((line, index) => (line.vatRate === rate ? [<Ref key={index} to={lineId(index)} />] : []));
1585
+ return (
1586
+ <Product key={rate} label={`VAT at ${percent(rate)}, ${invoice.id}`} unit="€" decimals={2}>
1587
+ <Given label={`VAT rate ${percent(rate)}`} value={rate} format="percent" />
1588
+ {sumOf(`Net at ${percent(rate)}, ${invoice.id}`, lines)}
1589
+ </Product>
1590
+ );
1591
+ }),
1592
+ )}
1593
+ </Plus>
1594
+ <Interim label={`${invoice.id}, ${invoice.supplier}`} unit="€" decimals={2} />
1595
+ </Chain>
1596
+ );
1597
+ }
1598
+
1599
+ export default function Invoices() {
1600
+ return (
1601
+ <Calculation aria-label="Invoices to be paid, March">
1602
+ <Sum label="To be paid" unit="€" decimals={2}>
1603
+ {OPEN.map(invoiceChain)}
1604
+ </Sum>
1605
+ </Calculation>
1606
+ );
1607
+ }
1608
+ ```
1609
+
1610
+ It imports `controlling.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1611
+
1011
1612
  ##### OEE of a hall, weighted by planned time
1012
1613
 
1614
+ 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.
1615
+
1013
1616
  ```tsx
1617
+ import { SHIFT_MINUTES } from "./plant";
1014
1618
  import { Calculation, Difference, Given, Product, Quotient, Ref, Sum } from "@umriss-ui/calculation";
1015
1619
 
1016
- /* Three lines, each with its own OEE derived from its own counts, weighted by
1017
- how long each was planned to run. A tree three levels deep, built from
1018
- data, each line's quantities named once and referred to by id - and line
1019
- C's downtime has not been booked. Its OEE is missing, so is
1020
- the hall's, and every line on the way says why; lines A and B stand as
1021
- they are. Nothing is averaged over a gap as if it were zero. */
1022
-
1023
1620
  interface Line {
1024
1621
  name: string;
1025
1622
  planned: number;
@@ -1029,10 +1626,12 @@ interface Line {
1029
1626
  good: number;
1030
1627
  }
1031
1628
 
1629
+ const BREAKS = 30;
1630
+
1032
1631
  const LINES: Line[] = [
1033
- { name: "A", planned: 450, downtime: 38, idealCycle: 0.8, total: 480, good: 461 },
1034
- { name: "B", planned: 450, downtime: 62, idealCycle: 1.2, total: 300, good: 291 },
1035
- { name: "C", planned: 240, downtime: null, idealCycle: 0.5, total: 402, good: 395 },
1632
+ { name: "A", planned: SHIFT_MINUTES - BREAKS, downtime: 38, idealCycle: 0.8, total: 480, good: 461 },
1633
+ { name: "B", planned: SHIFT_MINUTES - BREAKS, downtime: 62, idealCycle: 1.2, total: 300, good: 291 },
1634
+ { name: "C", planned: SHIFT_MINUTES / 2, downtime: null, idealCycle: 0.5, total: 402, good: 395 },
1036
1635
  ];
1037
1636
 
1038
1637
  function lineOee(line: Line) {
@@ -1081,3 +1680,1371 @@ export default function HallOee() {
1081
1680
  );
1082
1681
  }
1083
1682
  ```
1683
+
1684
+ It imports `plant.ts` from beside itself; the file stands once, under "Files the examples show" at the end.
1685
+
1686
+ ## Files the examples show
1687
+
1688
+ The demos' shared data, which some examples import from beside themselves. Copied with the example, it runs.
1689
+
1690
+ ### `controlling.ts`
1691
+
1692
+ ```ts
1693
+ /**
1694
+ * The controlling world: Carrow & Lisle, a mid-sized maker of office
1695
+ * furniture, and its finance team's view of the year 2026.
1696
+ *
1697
+ * The standing cast:
1698
+ * - `COST_CENTRES` - nine cost centres with their owner and monthly budget;
1699
+ * - `MONTHS` and `LEDGER` - per cost centre and month: the budget, the actual
1700
+ * where the month is closed (January and February), the forecast for every
1701
+ * month; Marketing overspends, IT runs under;
1702
+ * - `INVOICES` - incoming invoices with their lines (quantity, unit price,
1703
+ * discount, VAT rate), and `APPROVALS`, who signed which, or has yet to.
1704
+ *
1705
+ * Amounts in euros. "Now" is Tuesday, 17 March 2026. Plain data and small pure
1706
+ * functions, no imports: copy the file beside an example and it runs.
1707
+ */
1708
+
1709
+ /** A small LCG - the same numbers on every computer. */
1710
+ function random(seed: number): () => number {
1711
+ let s = seed >>> 0;
1712
+ return () => {
1713
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
1714
+ return s / 4294967296;
1715
+ };
1716
+ }
1717
+
1718
+ const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();
1719
+
1720
+ export interface CostCentre {
1721
+ id: string;
1722
+ name: string;
1723
+ owner: string;
1724
+ /** The budget of one month. */
1725
+ monthlyBudget: number;
1726
+ }
1727
+
1728
+ export const COST_CENTRES: readonly CostCentre[] = [
1729
+ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
1730
+ { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
1731
+ { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
1732
+ { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
1733
+ { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
1734
+ { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
1735
+ { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
1736
+ { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
1737
+ { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
1738
+ ];
1739
+
1740
+ /** The year's months, as `"2026-01"` … `"2026-12"`. */
1741
+ export const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
1742
+
1743
+ /** The months whose books are closed: they have an actual. */
1744
+ export const CLOSED_MONTHS = 2;
1745
+
1746
+ export interface LedgerRow {
1747
+ costCentre: string;
1748
+ month: string;
1749
+ budget: number;
1750
+ /** `null` while the month is open. */
1751
+ actual: number | null;
1752
+ forecast: number;
1753
+ }
1754
+
1755
+ /* Where a cost centre lands against its budget, as a factor: Marketing's
1756
+ spring campaign overspends, IT's delayed licences underspend. */
1757
+ const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
1758
+
1759
+ /** Budget, actual and forecast of every cost centre in every month. */
1760
+ export const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
1761
+ const r = random(310 + c);
1762
+ const tendency = TENDENCY[centre.id] ?? 1;
1763
+ return MONTHS.map((month, m) => {
1764
+ /* December pays the bonuses, August is the quiet month. */
1765
+ const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
1766
+ const budget = Math.round(centre.monthlyBudget * season);
1767
+ const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
1768
+ const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
1769
+ return { costCentre: centre.id, month, budget, actual, forecast };
1770
+ });
1771
+ });
1772
+
1773
+ export interface InvoiceLine {
1774
+ description: string;
1775
+ quantity: number;
1776
+ unitPrice: number;
1777
+ /** A fraction: 0.1 is ten per cent off. */
1778
+ discount: number;
1779
+ /** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
1780
+ vatRate: number;
1781
+ }
1782
+
1783
+ export interface Invoice {
1784
+ id: string;
1785
+ supplier: string;
1786
+ costCentre: string;
1787
+ received: number;
1788
+ due: number;
1789
+ status: "awaiting approval" | "approved" | "paid" | "rejected";
1790
+ lines: readonly InvoiceLine[];
1791
+ }
1792
+
1793
+ export const INVOICES: readonly Invoice[] = [
1794
+ {
1795
+ id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
1796
+ lines: [
1797
+ { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
1798
+ { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
1799
+ { description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
1800
+ ],
1801
+ },
1802
+ {
1803
+ id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
1804
+ lines: [
1805
+ { description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
1806
+ { description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
1807
+ ],
1808
+ },
1809
+ {
1810
+ id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
1811
+ lines: [
1812
+ { description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
1813
+ { description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
1814
+ ],
1815
+ },
1816
+ {
1817
+ id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
1818
+ lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
1819
+ },
1820
+ {
1821
+ id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
1822
+ lines: [
1823
+ { description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
1824
+ { description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
1825
+ ],
1826
+ },
1827
+ {
1828
+ id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
1829
+ lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
1830
+ },
1831
+ ];
1832
+
1833
+ export interface Approval {
1834
+ invoice: string;
1835
+ step: "cost centre" | "finance";
1836
+ approver: string;
1837
+ decision: "approved" | "rejected" | "pending";
1838
+ at?: number;
1839
+ comment?: string;
1840
+ }
1841
+
1842
+ /** Two signatures an invoice needs: its cost centre's owner, then finance. */
1843
+ export const APPROVALS: readonly Approval[] = [
1844
+ { invoice: "INV-26-0318", step: "cost centre", approver: "Olga Ivanova", decision: "pending" },
1845
+ { invoice: "INV-26-0317", step: "cost centre", approver: "Rafael Ortiz", decision: "approved", at: on(3, 16) },
1846
+ { invoice: "INV-26-0317", step: "finance", approver: "Martina Vogel", decision: "pending" },
1847
+ { invoice: "INV-26-0309", step: "cost centre", approver: "Kenji Arai", decision: "approved", at: on(3, 10) },
1848
+ { invoice: "INV-26-0309", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 11) },
1849
+ { invoice: "INV-26-0302", step: "cost centre", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
1850
+ { invoice: "INV-26-0302", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
1851
+ { invoice: "INV-26-0226", step: "cost centre", approver: "Helen Marsh", decision: "approved", at: on(2, 27) },
1852
+ { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
1853
+ { 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." },
1854
+ ];
1855
+ ```
1856
+
1857
+ ### `logistics.ts`
1858
+
1859
+ ```ts
1860
+ /**
1861
+ * The logistics world: Ferrow Parcel, a regional parcel carrier with three
1862
+ * depots.
1863
+ *
1864
+ * The standing cast:
1865
+ * - `DEPOTS`, `VEHICLES` (plate, type, payload, depot) and `DRIVERS`;
1866
+ * - `TOURS` - today's eight delivery tours, each with its vehicle, driver,
1867
+ * start, end, distance and stops; a stop has a delivery window and the
1868
+ * planned arrival, and a few arrive outside their window;
1869
+ * - `SHIPMENTS` - one per stop, with customer, weight, status and window;
1870
+ * - `VEHICLE_STATES` and `vehicleDay(vehicle)` - what a vehicle did through
1871
+ * the day, quarter hour by quarter hour, for a state band;
1872
+ * - `COST_RATES` - what a kilometre, an hour and a vehicle day cost, the
1873
+ * inputs of a cost per tour.
1874
+ *
1875
+ * "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
1876
+ * functions, no imports: copy the file beside an example and it runs.
1877
+ */
1878
+
1879
+ /** A small LCG - the same numbers on every computer. */
1880
+ function random(seed: number): () => number {
1881
+ let s = seed >>> 0;
1882
+ return () => {
1883
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
1884
+ return s / 4294967296;
1885
+ };
1886
+ }
1887
+
1888
+ const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
1889
+ const MINUTE = 60_000;
1890
+
1891
+ /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
1892
+ export const NOW = at(10, 30);
1893
+
1894
+ export const DEPOTS = [
1895
+ { id: "north", name: "North depot" },
1896
+ { id: "river", name: "Riverside depot" },
1897
+ { id: "east", name: "East Gate depot" },
1898
+ ] as const;
1899
+
1900
+ export interface Vehicle {
1901
+ id: string;
1902
+ plate: string;
1903
+ type: "van" | "e-van" | "truck";
1904
+ /** Payload, in kg. */
1905
+ capacity: number;
1906
+ depot: string;
1907
+ }
1908
+
1909
+ export const VEHICLES: readonly Vehicle[] = [
1910
+ { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
1911
+ { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
1912
+ { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
1913
+ { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
1914
+ { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
1915
+ { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
1916
+ { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
1917
+ { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
1918
+ ];
1919
+
1920
+ export const DRIVERS = [
1921
+ { id: "d1", name: "Martin Hale", depot: "north" },
1922
+ { id: "d2", name: "Nadia Petrova", depot: "north" },
1923
+ { id: "d3", name: "Owen Carter", depot: "north" },
1924
+ { id: "d4", name: "Lucia Romero", depot: "river" },
1925
+ { id: "d5", name: "Ben Adeyemi", depot: "river" },
1926
+ { id: "d6", name: "Hanna Berg", depot: "east" },
1927
+ { id: "d7", name: "Yusuf Demir", depot: "east" },
1928
+ { id: "d8", name: "Clara Wendt", depot: "east" },
1929
+ ] as const;
1930
+
1931
+ const CUSTOMERS = [
1932
+ "Holloway Garden Supplies",
1933
+ "Marlow & Finch Books",
1934
+ "Oakridge Pharmacy",
1935
+ "Brixley Cycles",
1936
+ "Tamsin's Bakery",
1937
+ "Northfold Office",
1938
+ "Pellham Hardware",
1939
+ "Greywick Studio",
1940
+ "Juniper Lane Florist",
1941
+ "Ashcombe Dental",
1942
+ ] as const;
1943
+
1944
+ export interface Stop {
1945
+ id: string;
1946
+ shipment: string;
1947
+ customer: string;
1948
+ /** The window the customer was promised, `[from, to]`. */
1949
+ window: readonly [number, number];
1950
+ /** When the tour plans to be there. */
1951
+ arrival: number;
1952
+ }
1953
+
1954
+ export interface Tour {
1955
+ id: string;
1956
+ vehicle: string;
1957
+ driver: string;
1958
+ depot: string;
1959
+ /** Leaves the depot, loaded; back at the depot. */
1960
+ from: number;
1961
+ to: number;
1962
+ /** Minutes of loading before `from`. */
1963
+ loading: number;
1964
+ km: number;
1965
+ stops: readonly Stop[];
1966
+ }
1967
+
1968
+ /** One tour per vehicle. Stops every half hour or so, each with a two-hour
1969
+ window; every seventh arrival misses its window - the late ones a
1970
+ dispatcher looks for. */
1971
+ function tour(index: number): Tour {
1972
+ const r = random(700 + index);
1973
+ const vehicle = VEHICLES[index]!;
1974
+ const truck = vehicle.type === "truck";
1975
+ const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
1976
+ const count = truck ? 5 : 8 + Math.floor(r() * 4);
1977
+ const stops: Stop[] = [];
1978
+ let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
1979
+ for (let i = 0; i < count; i++) {
1980
+ const n = index * 20 + i;
1981
+ const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
1982
+ /* A late stop's window closed half an hour or more before the arrival. */
1983
+ const late = i >= 2 && n % 7 === 3;
1984
+ const halfHour = 30 * MINUTE;
1985
+ const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
1986
+ stops.push({
1987
+ id: `s-${index + 1}-${i + 1}`,
1988
+ shipment: `FP-${(1_004_210 + n * 13).toString()}`,
1989
+ customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
1990
+ window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
1991
+ arrival,
1992
+ });
1993
+ arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
1994
+ }
1995
+ return {
1996
+ id: `T-${String(index + 1).padStart(2, "0")}`,
1997
+ vehicle: vehicle.id,
1998
+ driver: DRIVERS[index]!.id,
1999
+ depot: vehicle.depot,
2000
+ from,
2001
+ to: arrival + 30 * MINUTE,
2002
+ loading: truck ? 45 : 30,
2003
+ km: Math.round((truck ? 140 : 70) + r() * 40),
2004
+ stops,
2005
+ };
2006
+ }
2007
+
2008
+ export const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
2009
+
2010
+ export interface Shipment {
2011
+ id: string;
2012
+ customer: string;
2013
+ /** In kg. */
2014
+ weight: number;
2015
+ status: "delivered" | "out for delivery" | "failed attempt";
2016
+ window: readonly [number, number];
2017
+ tour: string;
2018
+ }
2019
+
2020
+ /** One shipment per stop. Before now, delivered - or a failed attempt, where
2021
+ nobody was in; after now, out for delivery. */
2022
+ export const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
2023
+ one.stops.map((stop) => {
2024
+ const n = Number(stop.shipment.slice(3));
2025
+ return {
2026
+ id: stop.shipment,
2027
+ customer: stop.customer,
2028
+ weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
2029
+ status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
2030
+ window: stop.window,
2031
+ tour: one.id,
2032
+ } satisfies Shipment;
2033
+ }),
2034
+ );
2035
+
2036
+ /** The closed set of vehicle states. The order is the code. */
2037
+ export const VEHICLE_STATES = [
2038
+ { label: "Driving", color: "#2f6fb4" },
2039
+ { label: "Loading", color: "#c08a2e" },
2040
+ { label: "Idle", color: "#8a8f98" },
2041
+ { label: "Break", color: "#6b8e5a" },
2042
+ ] as const;
2043
+
2044
+ export interface StatePoint {
2045
+ t: number;
2046
+ /** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
2047
+ state: number | null;
2048
+ }
2049
+
2050
+ /** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
2051
+ loading before its tour, driving between stops, idle at them now and then,
2052
+ and a break around noon. */
2053
+ export function vehicleDay(vehicleId: string): StatePoint[] {
2054
+ const one = TOURS.find((t) => t.vehicle === vehicleId);
2055
+ if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
2056
+ const r = random(900 + TOURS.indexOf(one));
2057
+ const points: StatePoint[] = [];
2058
+ for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
2059
+ let state: number | null = null;
2060
+ if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
2061
+ else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
2062
+ points.push({ t, state });
2063
+ }
2064
+ return points;
2065
+ }
2066
+
2067
+ /** What a tour costs, per unit - the inputs of a cost per tour. */
2068
+ export const COST_RATES = {
2069
+ /** Diesel, per litre; electricity, per kWh. */
2070
+ dieselPerLitre: 1.68,
2071
+ electricityPerKwh: 0.31,
2072
+ /** Consumption per 100 km. */
2073
+ per100Km: { van: 9.5, truck: 24, "e-van": 22 },
2074
+ /** A driver's hour, all in. */
2075
+ driverPerHour: 34.5,
2076
+ /** Lease, insurance and upkeep, per day. */
2077
+ vehiclePerDay: { van: 58, truck: 145, "e-van": 72 },
2078
+ } as const;
2079
+
2080
+ /* ---------------------------------------------------------------------------
2081
+ For @umriss-ui/charts: an e-van's battery, the North depot's week and
2082
+ fortnight, the delays by cause and the parcels per depot and hour.
2083
+ --------------------------------------------------------------------------- */
2084
+
2085
+ const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
2086
+ const HOUR = 60 * MINUTE;
2087
+
2088
+ export interface BatteryPoint {
2089
+ t: number;
2090
+ /** State of charge, in per cent; `null` before the tour and after it. */
2091
+ charge: number | null;
2092
+ }
2093
+
2094
+ /** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
2095
+ full when the tour leaves, draining while it drives, a little while it
2096
+ idles, and topped up at the depot's charger over the noon break. */
2097
+ export function batteryDay(vehicleId: string): BatteryPoint[] {
2098
+ let charge = 100;
2099
+ return vehicleDay(vehicleId).map(({ t, state }) => {
2100
+ if (state === null) return { t, charge: null };
2101
+ const now = charge;
2102
+ charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
2103
+ return { t, charge: Math.round(now * 10) / 10 };
2104
+ });
2105
+ }
2106
+
2107
+ /** The North depot's sorting hours: Monday to Friday, 06:00 to 22:00. */
2108
+ export const SORTING_HOURS = Array.from({ length: 5 }, (_, day) => ({ from: on(3, 9 + day, 6), to: on(3, 9 + day, 22) }));
2109
+
2110
+ export interface SortedPoint {
2111
+ t: number;
2112
+ /** Parcels sorted per hour. */
2113
+ parcels: number;
2114
+ }
2115
+
2116
+ /** Last week's sorting at the North depot, quarter hour by quarter hour -
2117
+ only in its sorting hours. */
2118
+ export const SORTED: readonly SortedPoint[] = (() => {
2119
+ const r = random(1963);
2120
+ const points: SortedPoint[] = [];
2121
+ let parcels = 420;
2122
+ for (const { from } of SORTING_HOURS) {
2123
+ for (let quarter = 0; quarter < 4 * 16; quarter++) {
2124
+ parcels += (r() - 0.5) * 26;
2125
+ points.push({ t: from + quarter * 15 * MINUTE, parcels: Math.round(parcels) });
2126
+ }
2127
+ }
2128
+ return points;
2129
+ })();
2130
+
2131
+ export interface DepotDay {
2132
+ /** Day of March. */
2133
+ day: number;
2134
+ /** Pallets that arrived, and that left on the tours. */
2135
+ arrived: number;
2136
+ dispatched: number;
2137
+ /** Pallets on hand at the end of the day. */
2138
+ onHand: number;
2139
+ /** The range the stock is meant to stay in; `null` while it was reset
2140
+ after the stocktake. */
2141
+ targetLow: number | null;
2142
+ targetHigh: number | null;
2143
+ /** A pallet count by hand - it never quite agrees with the books. */
2144
+ counted: number;
2145
+ }
2146
+
2147
+ /** The North depot's last fourteen days, 2 to 15 March. */
2148
+ export const DEPOT_DAYS: readonly DepotDay[] = (() => {
2149
+ const r = random(2026);
2150
+ let onHand = 62;
2151
+ return Array.from({ length: 14 }, (_, i) => {
2152
+ const arrived = 30 + r() * 25;
2153
+ const dispatched = 22 + r() * 20;
2154
+ onHand += (arrived - dispatched) * 0.35;
2155
+ const reset = i >= 6 && i <= 8;
2156
+ return {
2157
+ day: i + 2,
2158
+ arrived: Math.round(arrived),
2159
+ dispatched: Math.round(dispatched),
2160
+ onHand: Math.round(onHand),
2161
+ targetLow: reset ? null : Math.round(onHand - 9 - r() * 3),
2162
+ targetHigh: reset ? null : Math.round(onHand + 9 + r() * 3),
2163
+ counted: Math.round(onHand + (r() - 0.5) * 26),
2164
+ };
2165
+ });
2166
+ })();
2167
+
2168
+ export interface DayDelays {
2169
+ /** The position on the x axis: the working day's index. */
2170
+ day: number;
2171
+ name: string;
2172
+ /** Minutes late across all tours, by cause; `null` where it was not logged. */
2173
+ traffic: number;
2174
+ loading: number | null;
2175
+ access: number;
2176
+ }
2177
+
2178
+ /** Two working weeks, 2 to 13 March. On the first Wednesday the loading log
2179
+ was not kept - a gap, not a zero - and on the second Thursday a closed
2180
+ bridge takes the day. */
2181
+ export const DELAYS: readonly DayDelays[] = (() => {
2182
+ const r = random(1204);
2183
+ const names = ["Mon", "Tue", "Wed", "Thu", "Fri"];
2184
+ return Array.from({ length: 10 }, (_, day) => ({
2185
+ day,
2186
+ name: `${names[day % 5]} ${2 + day + 2 * Math.floor(day / 5)}`,
2187
+ traffic: Math.round((day === 8 ? 150 : 40) + r() * 30),
2188
+ loading: day === 2 ? null : Math.round(10 + r() * 45),
2189
+ access: Math.round(5 + r() * 25),
2190
+ }));
2191
+ })();
2192
+
2193
+ export interface DepotHour {
2194
+ t: number;
2195
+ /** Parcels loaded per hour at each depot. */
2196
+ north: number;
2197
+ river: number;
2198
+ east: number;
2199
+ }
2200
+
2201
+ /** Monday 06:00 to Tuesday 06:00, hour by hour. East Gate closes at night and
2202
+ loads nothing - 0, not nothing. */
2203
+ export const PARCELS_PER_HOUR: readonly DepotHour[] = (() => {
2204
+ const r = random(906);
2205
+ return Array.from({ length: 25 }, (_, i) => {
2206
+ const hour = (6 + i) % 24;
2207
+ const night = hour >= 22 || hour < 6;
2208
+ return {
2209
+ t: on(3, 16, 6) + i * HOUR,
2210
+ north: Math.round(120 + r() * 30),
2211
+ river: Math.round((night ? 60 : 90) + r() * 25),
2212
+ east: night ? 0 : Math.round(70 + r() * 30),
2213
+ };
2214
+ });
2215
+ })();
2216
+ ```
2217
+
2218
+ ### `operations.ts`
2219
+
2220
+ ```ts
2221
+ /**
2222
+ * The operations world: Quillmere, a platform on which small businesses take
2223
+ * online bookings, run as a set of services by six teams.
2224
+ *
2225
+ * The standing cast:
2226
+ * - `SERVICES` - eight services with their team, tier, latency objective and
2227
+ * availability target;
2228
+ * - `ENGINEERS` - the people on call, and `ONCALL`, who is primary and
2229
+ * secondary this week;
2230
+ * - `metrics(service)` - latency and error rate every five minutes of today up
2231
+ * to now; Checkout has a bad half hour from 09:40;
2232
+ * - `INCIDENTS` - the last week's incidents, one of them still open;
2233
+ * - `ALERT_TYPES` and `ALERTS` - what the alert list shows at 10:30;
2234
+ * - `DOWNTIME_MINUTES` - each service's downtime this month, for its
2235
+ * availability.
2236
+ *
2237
+ * "Now" is Tuesday, 17 March 2026, 10:30 local time. Plain data and small pure
2238
+ * functions, no imports: copy the file beside an example and it runs.
2239
+ */
2240
+
2241
+ /** A small LCG - the same numbers on every computer. */
2242
+ function random(seed: number): () => number {
2243
+ let s = seed >>> 0;
2244
+ return () => {
2245
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
2246
+ return s / 4294967296;
2247
+ };
2248
+ }
2249
+
2250
+ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
2251
+ const MINUTE = 60_000;
2252
+
2253
+ /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
2254
+ export const NOW = at(17, 10, 30);
2255
+
2256
+ export interface Service {
2257
+ id: string;
2258
+ name: string;
2259
+ team: string;
2260
+ /** 1 is customer-facing and pages at night; 3 waits for the morning. */
2261
+ tier: 1 | 2 | 3;
2262
+ /** The latency objective: the 95th percentile stays below this, in ms. */
2263
+ latencySlo: number;
2264
+ /** The availability promised for a month, in per cent. */
2265
+ availabilityTarget: number;
2266
+ }
2267
+
2268
+ export const SERVICES: readonly Service[] = [
2269
+ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
2270
+ { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
2271
+ { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
2272
+ { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
2273
+ { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
2274
+ { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
2275
+ { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
2276
+ { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
2277
+ ];
2278
+
2279
+ export interface Engineer {
2280
+ id: string;
2281
+ name: string;
2282
+ team: string;
2283
+ }
2284
+
2285
+ export const ENGINEERS: readonly Engineer[] = [
2286
+ { id: "priya", name: "Priya Raman", team: "Payments" },
2287
+ { id: "jonas", name: "Jonas Keller", team: "Payments" },
2288
+ { id: "ada", name: "Ada Mwangi", team: "Identity" },
2289
+ { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
2290
+ { id: "leila", name: "Leila Haddad", team: "Discovery" },
2291
+ { id: "sam", name: "Sam Okafor", team: "Messaging" },
2292
+ { id: "ines", name: "Ines Duarte", team: "Integrations" },
2293
+ { id: "felix", name: "Felix Brandt", team: "Insights" },
2294
+ ];
2295
+
2296
+ export interface OnCall {
2297
+ id: string;
2298
+ engineer: string;
2299
+ rotation: "primary" | "secondary";
2300
+ from: number;
2301
+ to: number;
2302
+ }
2303
+
2304
+ /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
2305
+ secondary of one day is the primary of the next. */
2306
+ export const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
2307
+ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
2308
+ { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
2309
+ ].map(({ rotation, engineer }) => ({
2310
+ id: `${rotation}-${16 + day}`,
2311
+ engineer,
2312
+ rotation,
2313
+ from: at(16 + day, 9),
2314
+ to: at(17 + day, 9),
2315
+ }))).flat();
2316
+
2317
+ export interface MetricPoint {
2318
+ t: number;
2319
+ /** Median and 95th percentile latency, in ms. */
2320
+ p50: number;
2321
+ p95: number;
2322
+ /** Failed requests, in per cent. */
2323
+ errorRate: number;
2324
+ /** Requests per minute. */
2325
+ requests: number;
2326
+ }
2327
+
2328
+ /** Requests per minute at the busiest hour, by tier. */
2329
+ const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
2330
+
2331
+ /** A service's latency and error rate every five minutes of today, from
2332
+ midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
2333
+ climbs well past its objective from 09:40 to 10:10, and its errors with it. */
2334
+ export function metrics(serviceId: string): MetricPoint[] {
2335
+ const index = SERVICES.findIndex((one) => one.id === serviceId);
2336
+ const service = SERVICES[index];
2337
+ if (service === undefined) throw new Error(`No service "${serviceId}".`);
2338
+ const r = random(1000 + index * 37);
2339
+ const points: MetricPoint[] = [];
2340
+ let drift = 0;
2341
+ for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
2342
+ const hour = (t - at(17, 0)) / (60 * MINUTE);
2343
+ const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
2344
+ drift = 0.8 * drift + (r() - 0.5) * 0.08;
2345
+ const base = service.latencySlo * (0.45 + 0.2 * load + drift);
2346
+ const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
2347
+ const p95 = Math.round(base * surge * (1 + r() * 0.08));
2348
+ points.push({
2349
+ t,
2350
+ p50: Math.round(p95 * (0.42 + r() * 0.06)),
2351
+ p95,
2352
+ errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
2353
+ requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
2354
+ });
2355
+ }
2356
+ return points;
2357
+ }
2358
+
2359
+ export interface Incident {
2360
+ id: string;
2361
+ title: string;
2362
+ service: string;
2363
+ severity: "SEV1" | "SEV2" | "SEV3";
2364
+ opened: number;
2365
+ acknowledged?: number;
2366
+ resolved?: number;
2367
+ /** An engineer's id. */
2368
+ assignee: string;
2369
+ }
2370
+
2371
+ export const INCIDENTS: readonly Incident[] = [
2372
+ { 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" },
2373
+ { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
2374
+ { 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" },
2375
+ { 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" },
2376
+ { 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" },
2377
+ { 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" },
2378
+ { 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" },
2379
+ { 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" },
2380
+ ];
2381
+
2382
+ /** A kind of alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
2383
+ export interface AlertType {
2384
+ id: string;
2385
+ label: string;
2386
+ priority: "high" | "medium" | "low";
2387
+ }
2388
+
2389
+ export const ALERT_TYPES: readonly AlertType[] = [
2390
+ { id: "checkout-latency", label: "Checkout · p95 latency above 300 ms", priority: "high" },
2391
+ { id: "checkout-errors", label: "Checkout · error rate above 2 %", priority: "high" },
2392
+ { id: "webhooks-queue", label: "Webhooks · queue older than 5 min", priority: "medium" },
2393
+ { id: "search-latency", label: "Search · p95 latency above 250 ms", priority: "medium" },
2394
+ { id: "images-disk", label: "Image service · disk 85 % full", priority: "low" },
2395
+ { id: "reports-job", label: "Reporting · nightly job late", priority: "low" },
2396
+ ];
2397
+
2398
+ /** One alert, shaped as `@umriss-ui/table`'s alarm list reads it. */
2399
+ export type Alert = {
2400
+ id: string;
2401
+ type: string;
2402
+ lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
2403
+ raised: number;
2404
+ resolved?: number;
2405
+ acknowledgedAt?: number;
2406
+ } & (
2407
+ | { availability?: "in-service" | "suppressed" | "disabled"; snooze?: undefined }
2408
+ | { availability: "snoozed"; snooze: { until: number; by: string } }
2409
+ );
2410
+
2411
+ /** The alerts as they stand at 10:30. */
2412
+ export const ALERTS: readonly Alert[] = [
2413
+ { id: "a-1", type: "checkout-latency", lifecycle: "active-acknowledged", raised: at(17, 9, 41), acknowledgedAt: at(17, 9, 46) },
2414
+ { id: "a-2", type: "checkout-errors", lifecycle: "resolved-unacknowledged", raised: at(17, 9, 43), resolved: at(17, 10, 11) },
2415
+ { id: "a-3", type: "webhooks-queue", lifecycle: "active-acknowledged", raised: at(17, 7, 12), acknowledgedAt: at(17, 8, 2) },
2416
+ { id: "a-4", type: "search-latency", lifecycle: "active-unacknowledged", raised: at(17, 10, 24) },
2417
+ { id: "a-5", type: "search-latency", lifecycle: "resolved-acknowledged", raised: at(17, 8, 5), resolved: at(17, 8, 9), acknowledgedAt: at(17, 8, 6) },
2418
+ { id: "a-6", type: "images-disk", lifecycle: "active-unacknowledged", raised: at(17, 6, 0), availability: "snoozed", snooze: { until: at(17, 14), by: "Tomasz Nowak" } },
2419
+ { id: "a-7", type: "reports-job", lifecycle: "active-unacknowledged", raised: at(17, 4, 30), availability: "disabled" },
2420
+ ];
2421
+
2422
+ /** The month so far: 1 March 00:00 to now, in minutes. */
2423
+ export const MONTH_MINUTES = (NOW - at(1, 0)) / MINUTE;
2424
+
2425
+ /** Minutes each service was down this month. With `MONTH_MINUTES` and the
2426
+ service's target, what an availability is worked out from. */
2427
+ export const DOWNTIME_MINUTES: Readonly<Record<string, number>> = {
2428
+ checkout: 34,
2429
+ billing: 18,
2430
+ "sign-in": 41,
2431
+ search: 85,
2432
+ images: 120,
2433
+ notifications: 12,
2434
+ webhooks: 210,
2435
+ reports: 95,
2436
+ };
2437
+
2438
+ /* ---------------------------------------------------------------------------
2439
+ For @umriss-ui/charts: last week at any resolution, a load test, single
2440
+ traced requests, the autoscaler's log, the expected range and yesterday's
2441
+ success rates.
2442
+ --------------------------------------------------------------------------- */
2443
+
2444
+ const HOUR = 60 * MINUTE;
2445
+ const DAY = 24 * HOUR;
2446
+
2447
+ /** Monday, 9 March 2026, 00:00 - the start of last week. */
2448
+ export const LAST_WEEK = at(9, 0);
2449
+
2450
+ /** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
2451
+ const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
2452
+
2453
+ /** A service's last week, Monday to Monday, one point every `step`
2454
+ milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
2455
+ 95th percentile more than doubles and its errors climb. The hour is counted,
2456
+ not asked of a Date - a week of seconds is 604,800 points. */
2457
+ export function week(serviceId: string, step: number): MetricPoint[] {
2458
+ const index = SERVICES.findIndex((one) => one.id === serviceId);
2459
+ const service = SERVICES[index];
2460
+ if (service === undefined) throw new Error(`No service "${serviceId}".`);
2461
+ const r = random(2000 + index * 37);
2462
+ const n = Math.floor((7 * DAY) / step);
2463
+ const points = new Array<MetricPoint>(n);
2464
+ /* The drift wanders at the same pace whatever the step. */
2465
+ const keep = Math.pow(0.8, step / (5 * MINUTE));
2466
+ const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
2467
+ let drift = 0;
2468
+ for (let i = 0; i < n; i++) {
2469
+ const offset = i * step;
2470
+ const day = Math.floor(offset / DAY);
2471
+ const hour = (offset % DAY) / HOUR;
2472
+ const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
2473
+ drift = keep * drift + (r() - 0.5) * kick;
2474
+ const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
2475
+ const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
2476
+ points[i] = {
2477
+ t: LAST_WEEK + offset,
2478
+ p50: Math.round(p95 * (0.42 + r() * 0.06)),
2479
+ p95,
2480
+ errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
2481
+ requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
2482
+ };
2483
+ }
2484
+ return points;
2485
+ }
2486
+
2487
+ /** When this morning's load test of Search began. */
2488
+ export interface LoadTestPoint {
2489
+ /** Minutes since the test began. */
2490
+ minute: number;
2491
+ /** CPU load of the busiest node, in per cent. */
2492
+ cpu: number;
2493
+ requestsPerHour: number;
2494
+ /** 95th percentile latency, in ms. */
2495
+ p95: number;
2496
+ }
2497
+
2498
+ /** An hour of load test, minute by minute: three magnitudes far apart. */
2499
+ export const LOAD_TEST: readonly LoadTestPoint[] = (() => {
2500
+ const r = random(99);
2501
+ let cpu = 21;
2502
+ let requestsPerHour = 128_000;
2503
+ let p95 = 640;
2504
+ return Array.from({ length: 60 }, (_, minute) => {
2505
+ cpu += (r() - 0.5) * 0.9;
2506
+ requestsPerHour += (r() - 0.5) * 9000;
2507
+ p95 += (r() - 0.5) * 40;
2508
+ return { minute, cpu, requestsPerHour, p95 };
2509
+ });
2510
+ })();
2511
+
2512
+ export interface Trace {
2513
+ t: number;
2514
+ /** How long the request took, end to end, in ms. */
2515
+ ms: number;
2516
+ }
2517
+
2518
+ /** Single Checkout requests traced end to end this morning, one every few
2519
+ minutes from 06:00 - individual measurements, nothing in between. Slow
2520
+ during the bad half hour, and now and then on their own. */
2521
+ export const TRACES: readonly Trace[] = (() => {
2522
+ const r = random(4040);
2523
+ const traces: Trace[] = [];
2524
+ for (let t = at(17, 6, 2); t <= NOW; t += Math.round(1 + r() * 4) * MINUTE) {
2525
+ const bad = t >= at(17, 9, 40) && t < at(17, 10, 10);
2526
+ const ms = 150 + r() * 90 + (bad ? 110 + r() * 120 : r() < 0.05 ? 150 : 0);
2527
+ traces.push({ t, ms: Math.round(ms) });
2528
+ }
2529
+ return traces;
2530
+ })();
2531
+
2532
+ export interface ReplicaChange {
2533
+ t: number;
2534
+ /** Checkout's instances from here on; `null` while the autoscaler was paused. */
2535
+ replicas: number | null;
2536
+ }
2537
+
2538
+ /** Checkout's instances today, logged only when the autoscaler changes them.
2539
+ It was paused for a database upgrade from 03:00 to 04:00; the last entry,
2540
+ at now, closes the hold. */
2541
+ export const REPLICAS: readonly ReplicaChange[] = [
2542
+ { t: at(17, 0), replicas: 4 },
2543
+ { t: at(17, 3), replicas: null },
2544
+ { t: at(17, 4), replicas: 3 },
2545
+ { t: at(17, 6, 30), replicas: 5 },
2546
+ { t: at(17, 8), replicas: 8 },
2547
+ { t: at(17, 9, 45), replicas: 12 },
2548
+ { t: at(17, 10, 15), replicas: 9 },
2549
+ { t: NOW, replicas: 9 },
2550
+ ];
2551
+
2552
+ export interface ExpectedPoint {
2553
+ t: number;
2554
+ /** The range the requests per minute are expected in; `null` where the
2555
+ job that computes it did not run. */
2556
+ low: number | null;
2557
+ high: number | null;
2558
+ }
2559
+
2560
+ /** The range a service's requests per minute are expected in today, every
2561
+ five minutes - the day's usual curve, give or take 15 %. The job that
2562
+ computes it skipped 03:00 to 04:00. */
2563
+ export function expected(serviceId: string): ExpectedPoint[] {
2564
+ const service = SERVICES.find((one) => one.id === serviceId);
2565
+ if (service === undefined) throw new Error(`No service "${serviceId}".`);
2566
+ const points: ExpectedPoint[] = [];
2567
+ for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
2568
+ const hour = (t - at(17, 0)) / HOUR;
2569
+ const usual = VOLUME[service.tier] * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
2570
+ const skipped = hour >= 3 && hour < 4;
2571
+ points.push({ t, low: skipped ? null : Math.round(usual * 0.85), high: skipped ? null : Math.round(usual * 1.15) });
2572
+ }
2573
+ return points;
2574
+ }
2575
+
2576
+ export interface SuccessCell {
2577
+ hour: number;
2578
+ /** The row: the service's place in `SERVICES`. */
2579
+ service: number;
2580
+ /** Requests that succeeded, in per cent; `null` where there were none. */
2581
+ success: number | null;
2582
+ }
2583
+
2584
+ /** Yesterday, per service and hour: the share of requests that succeeded.
2585
+ Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
2586
+ and Reporting takes no requests before 06:00 - no requests, no share. */
2587
+ export const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
2588
+ const r = random(23);
2589
+ const cells: SuccessCell[] = [];
2590
+ SERVICES.forEach((one, service) => {
2591
+ for (let hour = 0; hour < 24; hour++) {
2592
+ let success: number | null = 99.2 + r() * 0.8;
2593
+ if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
2594
+ if (hour === 3) success = 97.4 + r() * 1;
2595
+ if (one.id === "reports" && hour < 6) success = null;
2596
+ cells.push({ hour, service, success: success === null ? null : Math.round(success * 100) / 100 });
2597
+ }
2598
+ });
2599
+ return cells;
2600
+ })();
2601
+ ```
2602
+
2603
+ ### `plant.ts`
2604
+
2605
+ ```ts
2606
+ /**
2607
+ * The plant world: Brenholt Tile Works, a tile maker with one kiln line, and the
2608
+ * machine shop next door.
2609
+ *
2610
+ * The standing cast:
2611
+ * - the kiln line - press, dryer D1 and kiln K1 - over one early shift, minute
2612
+ * by minute: readings, samples, batches and the alarms they raise (`plant`);
2613
+ * - the machine shop's plan for Tuesday, 17 March 2026: seven stations, six
2614
+ * orders, their steps and the dependencies between them (`STATIONS`,
2615
+ * `ORDERS`, `STEPS`, `MOVES`).
2616
+ *
2617
+ * Plain data and small pure functions, no imports: copy the file beside an
2618
+ * example and it runs. Seeded, so the same seed gives the same numbers on
2619
+ * every machine; nothing here reads a clock.
2620
+ */
2621
+
2622
+ /* The few shapes the umriss packages expect, written out so this file needs
2623
+ no import. The data is structurally what the packages take. */
2624
+
2625
+ /** A limit set as `@umriss-ui/core` reads it. */
2626
+ interface LimitSet {
2627
+ target?: number;
2628
+ limits?: readonly { value: number; side: "upper" | "lower"; severity: "warning" | "alarm" }[];
2629
+ }
2630
+
2631
+ /** An alarm's dead band, as `@umriss-ui/table` reads it. */
2632
+ interface ReturnBand {
2633
+ direction: "upper" | "lower";
2634
+ limit: number;
2635
+ returnTo: number;
2636
+ }
2637
+
2638
+ /** A kind of alarm, as `@umriss-ui/table` reads it. */
2639
+ interface AlarmType {
2640
+ id: string;
2641
+ label: string;
2642
+ priority: "high" | "medium" | "low";
2643
+ returnBand?: ReturnBand;
2644
+ }
2645
+
2646
+ /** One alarm, as `@umriss-ui/table` reads it. */
2647
+ interface Alarm {
2648
+ id: string;
2649
+ type: string;
2650
+ lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
2651
+ raised: number;
2652
+ resolved?: number;
2653
+ acknowledgedAt?: number;
2654
+ availability?: "in-service" | "suppressed" | "disabled";
2655
+ }
2656
+
2657
+ /** A task, a subtask and a dependency, as `@umriss-ui/schedule` reads them. */
2658
+ interface Task {
2659
+ readonly id: string;
2660
+ readonly name?: string;
2661
+ readonly color: string;
2662
+ }
2663
+ interface Subtask {
2664
+ readonly id: string;
2665
+ readonly task: string;
2666
+ readonly lane: string;
2667
+ readonly from: number;
2668
+ readonly to: number;
2669
+ readonly leadIn?: number;
2670
+ readonly leadOut?: number;
2671
+ }
2672
+ interface Dependency {
2673
+ readonly id: string;
2674
+ readonly from: string;
2675
+ readonly to: string;
2676
+ readonly lag: number;
2677
+ readonly leaves?: "main" | "leadOut";
2678
+ readonly arrives?: "main" | "leadIn";
2679
+ }
2680
+
2681
+ /** A small LCG - reproducible across runs and platforms. */
2682
+ export function random(seed: number): () => number {
2683
+ let s = seed >>> 0;
2684
+ return () => {
2685
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
2686
+ return s / 4294967296;
2687
+ };
2688
+ }
2689
+
2690
+ /* ---------------------------------------------------------------------------
2691
+ The kiln line over an early shift.
2692
+
2693
+ Every part of a control room reads from this one shift - the trend, the
2694
+ tiles, the alarm list, the control chart, the plan and the OEE. The limit
2695
+ the kiln crosses in the trend is the alarm in the list, the verdict on the
2696
+ tile and the scrap in the OEE, because all four are read off the same
2697
+ minute of the same readings. The line counts in minutes from the start of
2698
+ the shift, and whoever draws it adds the instant the shift began.
2699
+
2700
+ The line generates alarms, which is exactly what the library does not do
2701
+ (ADR-0009): it decides that a reading above the limit is an alarm, and when
2702
+ it has come back far enough to clear. The table's model takes it from
2703
+ there.
2704
+ --------------------------------------------------------------------------- */
2705
+
2706
+ /** The early shift, 06:00 to 14:00. */
2707
+ export const SHIFT_MINUTES = 480;
2708
+
2709
+ /** The kiln's zone 3, in °C: the numbers every part reads it against, once.
2710
+ Between the two warnings lies the tolerance - a tile fired outside it is
2711
+ scrap; `returnTo` is the alarm's dead band. */
2712
+ export const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
2713
+
2714
+ /** The same numbers as the limit set the tile and the verdict read. */
2715
+ export const KILN_LIMITS: LimitSet = {
2716
+ target: KILN.target,
2717
+ limits: [
2718
+ { value: KILN.tolerance[1], side: "upper", severity: "warning" },
2719
+ { value: KILN.alarm, side: "upper", severity: "alarm" },
2720
+ { value: KILN.tolerance[0], side: "lower", severity: "warning" },
2721
+ ],
2722
+ };
2723
+
2724
+ /** The kiln fires one tile every quarter of a minute when nothing holds it up -
2725
+ the ideal cycle time of the OEE's performance. */
2726
+ export const IDEAL_CYCLE_MINUTES = 0.25;
2727
+
2728
+ /** The kiln alarm's condition and its dead band, as the table's model reads them. */
2729
+ const KILN_RETURN: ReturnBand = { direction: "upper", limit: KILN.alarm, returnTo: KILN.returnTo };
2730
+
2731
+ /** What the four measuring points of the line can report. */
2732
+ export const ALARM_TYPES: readonly AlarmType[] = [
2733
+ {
2734
+ id: "kiln-high",
2735
+ label: "Kiln K1 · zone 3 above alarm limit",
2736
+ priority: "high",
2737
+ /* The dead band: back below `returnTo`, not merely below the limit, or a
2738
+ reading riding on the limit would raise an alarm a minute. */
2739
+ returnBand: KILN_RETURN,
2740
+ },
2741
+ { id: "exit-silent", label: "Kiln K1 · exit pyrometer sends nothing", priority: "medium" },
2742
+ { id: "belt-empty", label: "Kiln K1 · belt empty", priority: "medium" },
2743
+ { id: "dryer-fan", label: "Dryer D1 · fan vibration high", priority: "low" },
2744
+ ];
2745
+
2746
+ /** One minute of the line. */
2747
+ export interface Reading {
2748
+ /** Minutes since the start of the shift. */
2749
+ minute: number;
2750
+ /** Zone 3 of the kiln, in °C. */
2751
+ kiln: number;
2752
+ /** The exit pyrometer, in °C - `null` while it sends nothing. */
2753
+ exit: number | null;
2754
+ /** Whether the belt runs. */
2755
+ running: boolean;
2756
+ /** Tiles out of the kiln in this minute. */
2757
+ fired: number;
2758
+ /** Of them, the ones fired inside the tolerance. */
2759
+ good: number;
2760
+ }
2761
+
2762
+ /** A tile taken off the belt and measured - one every ten minutes while it runs. */
2763
+ export interface Sample {
2764
+ minute: number;
2765
+ /** The tile's length after firing, in mm (nominal 600). */
2766
+ length: number;
2767
+ }
2768
+
2769
+ /** A batch of tiles through the three stations. */
2770
+ export interface Batch {
2771
+ id: string;
2772
+ name: string;
2773
+ /** How many tiles the batch is planned for. */
2774
+ tiles: number;
2775
+ /** Minutes since the start of the shift, per station. */
2776
+ press: readonly [number, number];
2777
+ dryer: readonly [number, number];
2778
+ kiln: readonly [number, number];
2779
+ }
2780
+
2781
+ export interface Plant {
2782
+ readings: readonly Reading[];
2783
+ samples: readonly Sample[];
2784
+ batches: readonly Batch[];
2785
+ }
2786
+
2787
+ /* When the exit pyrometer goes silent, and for how long. Fixed rather than
2788
+ seeded, so that a running control room shows its reading turn stale, then
2789
+ lost, and come back. */
2790
+ const SILENT_FROM = 288;
2791
+ const SILENT_FOR = 50;
2792
+
2793
+ /* The dryer's vibration sensor is under maintenance all shift: its alarm is
2794
+ active and disabled. */
2795
+ const DRYER_FAN_RAISED = 20;
2796
+
2797
+ /** The shift, from a seed. */
2798
+ export function plant(seed: number): Plant {
2799
+ const r = random(seed);
2800
+
2801
+ /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
2802
+ into the shift, for half an hour, peaking near 1242 °C. The operator stops
2803
+ the feed twenty-two minutes in, for eighteen minutes. The noise around it
2804
+ is too small to reach a warning on its own, so this is the only crossing -
2805
+ which is what the tests hold. */
2806
+ const surgeFrom = 150 + Math.floor(r() * 60);
2807
+ const surgeFor = 30;
2808
+ const stopFrom = surgeFrom + 22;
2809
+ const stopFor = 18;
2810
+
2811
+ /* The plan: batches through the kiln one after another, each pressed an
2812
+ hour and dried half an hour before - the first ones during the night
2813
+ shift. Tiles come out of the kiln only while a batch is in it, so the
2814
+ OEE's count and the batches' counts are the same tiles. The plan is a
2815
+ plan: a stop of the belt does not move it. */
2816
+ const batches: Batch[] = [];
2817
+ for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
2818
+ const length = 70 + Math.floor(r() * 25);
2819
+ batches.push({
2820
+ id: `b-${4121 + i}`,
2821
+ name: `B-${4121 + i}`,
2822
+ tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
2823
+ press: [at - 60, at - 60 + length],
2824
+ dryer: [at - 30, at - 30 + length],
2825
+ kiln: [at, at + length],
2826
+ });
2827
+ at += length + 5;
2828
+ }
2829
+
2830
+ const readings: Reading[] = [];
2831
+ const samples: Sample[] = [];
2832
+ let drift = 0;
2833
+ for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
2834
+ drift = 0.9 * drift + (r() - 0.5) * 3;
2835
+ const into = minute - surgeFrom;
2836
+ const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
2837
+ const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
2838
+
2839
+ const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
2840
+ const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
2841
+ const jam = r() < 0.12;
2842
+ const fired = running && loaded ? (jam ? 3 : 4) : 0;
2843
+ const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
2844
+ const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
2845
+ const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
2846
+
2847
+ readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
2848
+
2849
+ if (running && minute % 10 === 0) {
2850
+ /* Hotter shrinks more: the length follows the kiln, so the control
2851
+ chart sees the excursion the trend sees. */
2852
+ const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
2853
+ samples.push({ minute, length: Math.round(length * 100) / 100 });
2854
+ }
2855
+ }
2856
+
2857
+ return { readings, samples, batches };
2858
+ }
2859
+
2860
+ /** The readings up to and including the minute. */
2861
+ export function upTo<T extends { minute: number }>(series: readonly T[], minute: number): readonly T[] {
2862
+ return series.filter((one) => one.minute <= minute);
2863
+ }
2864
+
2865
+ /** How long ago the exit pyrometer last reported, in minutes - `0` while it
2866
+ reports. What the tile's freshness is told. */
2867
+ export function exitSilence(p: Plant, minute: number): number {
2868
+ for (let m = minute; m >= 0; m--) {
2869
+ if (p.readings[m]?.exit !== null) return minute - m;
2870
+ }
2871
+ return minute;
2872
+ }
2873
+
2874
+ /** When a condition held, as spans of minutes: `[from, to)`, `to` absent while
2875
+ it still holds at the minute. */
2876
+ function spans(
2877
+ readings: readonly Reading[],
2878
+ minute: number,
2879
+ raise: (one: Reading) => boolean,
2880
+ clear: (one: Reading) => boolean,
2881
+ ): { from: number; to?: number }[] {
2882
+ const found: { from: number; to?: number }[] = [];
2883
+ let open: { from: number; to?: number } | null = null;
2884
+ for (const one of readings) {
2885
+ if (one.minute > minute) break;
2886
+ if (open === null && raise(one)) {
2887
+ open = { from: one.minute };
2888
+ found.push(open);
2889
+ } else if (open !== null && clear(one)) {
2890
+ open.to = one.minute;
2891
+ open = null;
2892
+ }
2893
+ }
2894
+ return found;
2895
+ }
2896
+
2897
+ /**
2898
+ * The alarms of the shift as they stand at the minute: raised, resolved - and
2899
+ * acknowledged where the operator did, at the instant he did.
2900
+ *
2901
+ * `start` is the instant the shift began; the alarms carry instants because the
2902
+ * table's model does.
2903
+ */
2904
+ export function alarmsAt(
2905
+ p: Plant,
2906
+ minute: number,
2907
+ start: number,
2908
+ acknowledged: ReadonlyMap<string, number> = new Map(),
2909
+ ): Alarm[] {
2910
+ const instant = (m: number) => start + m * 60_000;
2911
+ const alarms: Alarm[] = [];
2912
+
2913
+ const add = (
2914
+ type: string,
2915
+ span: { from: number; to?: number },
2916
+ extra: { availability?: "suppressed" | "disabled" } = {},
2917
+ ) => {
2918
+ alarms.push({
2919
+ id: `${type}-${span.from}`,
2920
+ type,
2921
+ lifecycle: span.to === undefined ? "active-unacknowledged" : "resolved-unacknowledged",
2922
+ raised: instant(span.from),
2923
+ ...(span.to === undefined ? {} : { resolved: instant(span.to) }),
2924
+ ...extra,
2925
+ });
2926
+ };
2927
+
2928
+ for (const span of spans(p.readings, minute, (one) => one.kiln > KILN_RETURN.limit, (one) => one.kiln <= KILN_RETURN.returnTo)) {
2929
+ add("kiln-high", span);
2930
+ }
2931
+ for (const span of spans(p.readings, minute, (one) => one.exit === null, (one) => one.exit !== null)) {
2932
+ add("exit-silent", span);
2933
+ }
2934
+ /* The belt runs empty because the operator stopped the feed: the plant's
2935
+ logic knows it, so the alarm is suppressed - there, and neutral. */
2936
+ for (const span of spans(p.readings, minute, (one) => !one.running, (one) => one.running)) {
2937
+ add("belt-empty", span, { availability: "suppressed" });
2938
+ }
2939
+ if (minute >= DRYER_FAN_RAISED) {
2940
+ add("dryer-fan", { from: DRYER_FAN_RAISED }, { availability: "disabled" });
2941
+ }
2942
+
2943
+ return alarms.map((alarm) => {
2944
+ const at = acknowledged.get(alarm.id);
2945
+ if (at === undefined) return alarm;
2946
+ const lifecycle = alarm.resolved === undefined ? "active-acknowledged" : "resolved-acknowledged";
2947
+ return { ...alarm, lifecycle, acknowledgedAt: at };
2948
+ });
2949
+ }
2950
+
2951
+ /** The shift's figures for the OEE, up to and including the minute. */
2952
+ export interface ShiftCount {
2953
+ /** Minutes of the shift so far. */
2954
+ planned: number;
2955
+ /** Minutes the belt stood. */
2956
+ downtime: number;
2957
+ /** Tiles out of the kiln. */
2958
+ total: number;
2959
+ /** Of them, inside the tolerance. */
2960
+ good: number;
2961
+ }
2962
+
2963
+ export function countAt(p: Plant, minute: number): ShiftCount {
2964
+ const so = upTo(p.readings, minute);
2965
+ return {
2966
+ planned: so.length,
2967
+ downtime: so.filter((one) => !one.running).length,
2968
+ total: so.reduce((sum, one) => sum + one.fired, 0),
2969
+ good: so.reduce((sum, one) => sum + one.good, 0),
2970
+ };
2971
+ }
2972
+
2973
+ /* ---------------------------------------------------------------------------
2974
+ The machine shop's plan: seven stations, six orders on their way through
2975
+ them on Tuesday, 17 March 2026 - the day the browser suites freeze the
2976
+ clock on.
2977
+
2978
+ Written out, not generated from a seed: a planner reads these as a day's
2979
+ plan, and a finding is placed where it is on purpose - the overlap on the
2980
+ mill, the violated dependency into the paint shop.
2981
+ --------------------------------------------------------------------------- */
2982
+
2983
+ const planAt = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
2984
+ const min = (n: number) => n * 60_000;
2985
+
2986
+ export const DAY_OF_PLAN: readonly [number, number] = [planAt(5, 30), planAt(18)];
2987
+
2988
+ export const STATIONS = [
2989
+ { id: "saw", label: "Saw 1" },
2990
+ { id: "lathe-1", label: "Lathe 1" },
2991
+ { id: "lathe-2", label: "Lathe 2" },
2992
+ { id: "mill", label: "Mill" },
2993
+ { id: "press", label: "Press 2" },
2994
+ { id: "paint", label: "Paint shop" },
2995
+ { id: "qa", label: "Inspection" },
2996
+ ] as const;
2997
+
2998
+ /* Colours in both schemes, as the application's own tokens would give them. */
2999
+ export const ORDERS: readonly Task[] = [
3000
+ { id: "a-2041", name: "A-2041 Housing", color: "light-dark(#2563eb, #6b9bff)" },
3001
+ { id: "a-2042", name: "A-2042 Shaft", color: "light-dark(#0d9488, #3cc7b8)" },
3002
+ { id: "a-2043", name: "A-2043 Bracket", color: "light-dark(#c2410c, #f08a52)" },
3003
+ { id: "a-2044", name: "A-2044 Flange", color: "light-dark(#7c3aed, #a98bfa)" },
3004
+ { id: "a-2045", name: "A-2045 Cover", color: "light-dark(#be185d, #f06aa6)" },
3005
+ { id: "a-2046", name: "A-2046 Axle", color: "light-dark(#4d7c0f, #8fc43e)" },
3006
+ ];
3007
+
3008
+ export const STEPS: readonly Subtask[] = [
3009
+ { id: "a-2041-1", task: "a-2041", lane: "saw", from: planAt(6), to: planAt(7), leadIn: min(15), leadOut: min(10) },
3010
+ { id: "a-2041-2", task: "a-2041", lane: "mill", from: planAt(8), to: planAt(10, 30), leadIn: min(30), leadOut: min(15) },
3011
+ { id: "a-2041-3", task: "a-2041", lane: "qa", from: planAt(11, 30), to: planAt(12, 15) },
3012
+
3013
+ { id: "a-2042-1", task: "a-2042", lane: "saw", from: planAt(7, 30), to: planAt(8, 15), leadIn: min(10) },
3014
+ { id: "a-2042-2", task: "a-2042", lane: "lathe-1", from: planAt(9), to: planAt(11), leadIn: min(20), leadOut: min(15) },
3015
+ { id: "a-2042-3", task: "a-2042", lane: "qa", from: planAt(13), to: planAt(13, 30) },
3016
+
3017
+ { id: "a-2043-1", task: "a-2043", lane: "press", from: planAt(6, 30), to: planAt(8), leadIn: min(30), leadOut: min(15) },
3018
+ { id: "a-2043-2", task: "a-2043", lane: "mill", from: planAt(10), to: planAt(11, 30), leadIn: min(15) },
3019
+ { id: "a-2043-3", task: "a-2043", lane: "paint", from: planAt(12), to: planAt(14), leadIn: min(20), leadOut: min(20) },
3020
+
3021
+ { id: "a-2044-1", task: "a-2044", lane: "lathe-2", from: planAt(6), to: planAt(8, 30), leadIn: min(20), leadOut: min(10) },
3022
+ { id: "a-2044-2", task: "a-2044", lane: "press", from: planAt(9, 30), to: planAt(10, 45), leadIn: min(25) },
3023
+ { id: "a-2044-3", task: "a-2044", lane: "paint", from: planAt(14, 45), to: planAt(16), leadIn: min(15), leadOut: min(15) },
3024
+
3025
+ { id: "a-2045-1", task: "a-2045", lane: "lathe-1", from: planAt(12), to: planAt(13, 30), leadIn: min(15), leadOut: min(10) },
3026
+ { id: "a-2045-2", task: "a-2045", lane: "press", from: planAt(14, 15), to: planAt(15), leadIn: min(20) },
3027
+ { id: "a-2045-3", task: "a-2045", lane: "qa", from: planAt(15, 45), to: planAt(16, 30) },
3028
+
3029
+ { id: "a-2046-1", task: "a-2046", lane: "lathe-2", from: planAt(10), to: planAt(12), leadIn: min(20), leadOut: min(15) },
3030
+ { id: "a-2046-2", task: "a-2046", lane: "mill", from: planAt(13), to: planAt(14, 30), leadIn: min(20), leadOut: min(10) },
3031
+ { id: "a-2046-3", task: "a-2046", lane: "qa", from: planAt(15), to: planAt(15, 30) },
3032
+ ];
3033
+
3034
+ export const MOVES: readonly Dependency[] = [
3035
+ { id: "t-2041-1", from: "a-2041-1", to: "a-2041-2", lag: min(10) },
3036
+ { id: "t-2041-2", from: "a-2041-2", to: "a-2041-3", lag: min(20) },
3037
+ { id: "t-2042-1", from: "a-2042-1", to: "a-2042-2", lag: min(15) },
3038
+ { id: "t-2042-2", from: "a-2042-2", to: "a-2042-3", lag: min(30), leaves: "main" },
3039
+ { id: "t-2043-1", from: "a-2043-1", to: "a-2043-2", lag: min(45) },
3040
+ /* Leaves the mill at 11:30 and has 10 minutes to reach the paint shop's
3041
+ lead-in at 11:40 - it takes 25: a violated dependency, on purpose. */
3042
+ { id: "t-2043-2", from: "a-2043-2", to: "a-2043-3", lag: min(25) },
3043
+ { id: "t-2044-1", from: "a-2044-1", to: "a-2044-2", lag: min(20) },
3044
+ { id: "t-2044-2", from: "a-2044-2", to: "a-2044-3", lag: min(60), arrives: "main" },
3045
+ { id: "t-2045-1", from: "a-2045-1", to: "a-2045-2", lag: min(15) },
3046
+ { id: "t-2045-2", from: "a-2045-2", to: "a-2045-3", lag: min(15) },
3047
+ { id: "t-2046-1", from: "a-2046-1", to: "a-2046-2", lag: min(20) },
3048
+ { id: "t-2046-2", from: "a-2046-2", to: "a-2046-3", lag: min(10), leaves: "main" },
3049
+ ];
3050
+ ```