@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/CHANGELOG.md +12 -0
- package/README.md +23 -26
- package/docs/llms-full.md +2461 -494
- package/package.json +4 -4
package/docs/llms-full.md
CHANGED
|
@@ -1,8 +1,568 @@
|
|
|
1
|
-
# @umriss-ui/calculation 0.3.
|
|
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
|
|
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
|
-
|
|
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="
|
|
35
|
-
<Sum label="
|
|
36
|
-
<Given label="
|
|
37
|
-
<Given label="
|
|
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
|
-
#####
|
|
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
|
-
|
|
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={
|
|
65
|
-
<Given label="Downtime" value={
|
|
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={
|
|
72
|
-
<Given id="total" label="
|
|
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
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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="
|
|
657
|
+
<Calculation aria-label="Invoice INV-26-0318, Brandlow Office Supply">
|
|
103
658
|
<Chain>
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
</
|
|
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="
|
|
115
|
-
<Given label="
|
|
116
|
-
<
|
|
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
|
-
<
|
|
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="
|
|
122
|
-
<Given label="
|
|
123
|
-
<Ref to="
|
|
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="
|
|
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
|
-
####
|
|
712
|
+
#### Known limits
|
|
146
713
|
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#####
|
|
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="
|
|
178
|
-
<Quotient label="
|
|
179
|
-
<Given label="
|
|
180
|
-
<Given label="
|
|
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
|
-
#####
|
|
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="
|
|
197
|
-
<Difference label="
|
|
198
|
-
<Given label="
|
|
199
|
-
<Given label="
|
|
200
|
-
<Given label="
|
|
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
|
-
#####
|
|
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="
|
|
219
|
-
<Product label="
|
|
220
|
-
<Given label="
|
|
221
|
-
<Given label="
|
|
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
|
-
#####
|
|
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,
|
|
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="
|
|
240
|
-
<Quotient label="
|
|
241
|
-
<
|
|
242
|
-
<Given label="
|
|
243
|
-
<Given id="
|
|
244
|
-
</
|
|
245
|
-
<
|
|
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
|
-
#####
|
|
815
|
+
##### Build operands from data
|
|
256
816
|
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
826
|
+
export default function FromData() {
|
|
271
827
|
return (
|
|
272
|
-
<Calculation aria-label="
|
|
273
|
-
<Product label="
|
|
274
|
-
{
|
|
275
|
-
<Quotient key={
|
|
276
|
-
<
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
856
|
+
<Calculation aria-label="Cost per stop, tour T-03">
|
|
297
857
|
<Quotient
|
|
298
|
-
label="
|
|
299
|
-
unit="
|
|
300
|
-
decimals={
|
|
301
|
-
limits={[
|
|
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="
|
|
304
|
-
<Given label="
|
|
305
|
-
<Given label="
|
|
306
|
-
<Given label="
|
|
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="
|
|
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
|
-
#####
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
#####
|
|
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
|
-
|
|
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
|
-
["
|
|
461
|
-
["
|
|
462
|
-
["
|
|
463
|
-
["
|
|
464
|
-
["
|
|
465
|
-
["
|
|
466
|
-
["
|
|
467
|
-
["
|
|
468
|
-
["
|
|
469
|
-
["
|
|
470
|
-
["
|
|
471
|
-
["
|
|
472
|
-
["
|
|
473
|
-
["
|
|
474
|
-
["
|
|
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="
|
|
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="
|
|
1073
|
+
<Interim label="Cloud bill, March" unit="€" decimals={2} />
|
|
487
1074
|
</Chain>
|
|
488
1075
|
</Calculation>
|
|
489
1076
|
);
|
|
490
1077
|
}
|
|
491
1078
|
```
|
|
492
1079
|
|
|
493
|
-
#####
|
|
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
|
-
|
|
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="
|
|
532
|
-
<Quotient label="
|
|
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="
|
|
535
|
-
<Minus label="
|
|
536
|
-
<Minus label="
|
|
537
|
-
<Plus label="
|
|
538
|
-
<Interim label="
|
|
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="
|
|
1098
|
+
<Ref to="out" />
|
|
541
1099
|
</Quotient>
|
|
542
1100
|
</Calculation>
|
|
543
1101
|
);
|
|
544
1102
|
}
|
|
545
1103
|
```
|
|
546
1104
|
|
|
547
|
-
#####
|
|
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,
|
|
1110
|
+
import { Calculation, Chain, Given, Interim, Minus, Product } from "@umriss-ui/calculation";
|
|
551
1111
|
|
|
552
|
-
|
|
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="
|
|
1114
|
+
<Calculation aria-label="Error budget of Checkout, March">
|
|
559
1115
|
<Chain>
|
|
560
|
-
<
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
<Minus label="
|
|
565
|
-
<Minus label="
|
|
566
|
-
<Minus label="
|
|
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="
|
|
569
|
-
unit="
|
|
1124
|
+
label="Budget left"
|
|
1125
|
+
unit="min"
|
|
1126
|
+
decimals={1}
|
|
570
1127
|
limits={[
|
|
571
|
-
{ value:
|
|
572
|
-
{ value:
|
|
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
|
-
####
|
|
623
|
-
|
|
624
|
-
##### No precedence, and a times alone
|
|
1183
|
+
#### Known limits
|
|
625
1184
|
|
|
626
|
-
A chain
|
|
1185
|
+
- A chain in view does not fold; as an operand of a tree it folds to its last interim.
|
|
627
1186
|
|
|
628
|
-
|
|
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,
|
|
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
|
-
#####
|
|
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="
|
|
1219
|
+
<Calculation aria-label="Error rate of Checkout, last five minutes">
|
|
667
1220
|
<Quotient
|
|
668
|
-
label="
|
|
1221
|
+
label="Error rate, Checkout"
|
|
669
1222
|
format="percent"
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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="
|
|
675
|
-
<Given label="
|
|
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
|
-
#####
|
|
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="
|
|
694
|
-
<Product label="
|
|
695
|
-
<Given label="
|
|
696
|
-
<Given label="Price" value={
|
|
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
|
-
#####
|
|
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,
|
|
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="
|
|
1267
|
+
<Calculation aria-label="Rechnung INV-26-0317, Messestand">
|
|
718
1268
|
<Chain>
|
|
719
|
-
<Given id="
|
|
720
|
-
<
|
|
721
|
-
<Product label="
|
|
722
|
-
<Given label="
|
|
723
|
-
<Ref to="
|
|
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
|
-
</
|
|
726
|
-
<Interim label="
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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="
|
|
782
|
-
<
|
|
783
|
-
<
|
|
784
|
-
|
|
785
|
-
<Given
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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="
|
|
818
|
-
<
|
|
819
|
-
<Quotient label="
|
|
820
|
-
<
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
<Given label="
|
|
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
|
-
|
|
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
|
-
#####
|
|
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,
|
|
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
|
|
845
|
-
<Sum label="All three
|
|
846
|
-
{
|
|
847
|
-
<Quotient key={
|
|
848
|
-
<
|
|
849
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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="
|
|
1417
|
+
<Calculation aria-label="Payments at risk, this morning">
|
|
874
1418
|
<Chain>
|
|
875
|
-
<Product label="
|
|
876
|
-
<Given label="
|
|
877
|
-
<Given label="
|
|
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="
|
|
881
|
-
<Given label="
|
|
882
|
-
<Given label="
|
|
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="
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
|
1464
|
+
export default function CostPerTour() {
|
|
940
1465
|
return (
|
|
941
|
-
<Calculation aria-label="Cost per
|
|
1466
|
+
<Calculation aria-label="Cost per stop, all tours, Tuesday">
|
|
942
1467
|
<Chain>
|
|
943
|
-
<
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
<Given label={`
|
|
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
|
-
|
|
971
|
-
|
|
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="
|
|
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="
|
|
985
|
-
value={
|
|
986
|
-
|
|
987
|
-
|
|
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="
|
|
996
|
-
<
|
|
997
|
-
|
|
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
|
-
<
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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:
|
|
1034
|
-
{ name: "B", planned:
|
|
1035
|
-
{ name: "C", planned:
|
|
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
|
+
```
|