@umriss-ui/calculation 0.2.4 → 0.3.1
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 +24 -0
- package/README.md +29 -26
- package/dist/calculation.css +1 -1
- package/dist/calculation.js +133 -133
- package/docs/llms-full.md +3050 -0
- package/package.json +5 -4
|
@@ -0,0 +1,3050 @@
|
|
|
1
|
+
# @umriss-ui/calculation 0.3.1
|
|
2
|
+
|
|
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
|
+
|
|
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
|
+
```
|
|
566
|
+
|
|
567
|
+
## Writing a calculation
|
|
568
|
+
|
|
569
|
+
Two forms written as elements: a tree for a figure put together from factors, a chain for a sheet read top to bottom.
|
|
570
|
+
|
|
571
|
+
### Calculation
|
|
572
|
+
|
|
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.
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
import { Calculation, Given, Sum, Product, Quotient, Ref, Chain, Plus, Interim } from "@umriss-ui/calculation";
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/calculation
|
|
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
|
+
|
|
587
|
+
#### Examples
|
|
588
|
+
|
|
589
|
+
##### Two numbers and a sum
|
|
590
|
+
|
|
591
|
+
Two givens and the operator that joins them; the result stands beneath its operands, above a double rule.
|
|
592
|
+
|
|
593
|
+
```tsx
|
|
594
|
+
import { Calculation, Given, Sum } from "@umriss-ui/calculation";
|
|
595
|
+
|
|
596
|
+
export default function FirstSum() {
|
|
597
|
+
return (
|
|
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" />
|
|
602
|
+
</Sum>
|
|
603
|
+
</Calculation>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
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.
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
import { countAt, IDEAL_CYCLE_MINUTES, plant, SHIFT_MINUTES } from "./plant";
|
|
614
|
+
import { Calculation, Difference, Given, Product, Quotient, Ref } from "@umriss-ui/calculation";
|
|
615
|
+
|
|
616
|
+
const COUNT = countAt(plant(17), SHIFT_MINUTES);
|
|
617
|
+
|
|
618
|
+
export default function Oee() {
|
|
619
|
+
return (
|
|
620
|
+
<Calculation aria-label="OEE of the kiln line, early shift">
|
|
621
|
+
<Product label="OEE" format="percent" target={0.85}>
|
|
622
|
+
<Quotient label="Availability" format="percent">
|
|
623
|
+
<Difference id="runtime" label="Run time" unit="min">
|
|
624
|
+
<Given id="planned" label="Planned production time" value={COUNT.planned} unit="min" />
|
|
625
|
+
<Given label="Downtime" value={COUNT.downtime} unit="min" />
|
|
626
|
+
</Difference>
|
|
627
|
+
<Ref to="planned" />
|
|
628
|
+
</Quotient>
|
|
629
|
+
<Quotient label="Performance" format="percent">
|
|
630
|
+
<Product label="Ideal run time" unit="min">
|
|
631
|
+
<Given label="Ideal cycle time" value={IDEAL_CYCLE_MINUTES} unit="min/tile" />
|
|
632
|
+
<Given id="total" label="Tiles fired" value={COUNT.total} unit="tiles" />
|
|
633
|
+
</Product>
|
|
634
|
+
<Ref to="runtime" />
|
|
635
|
+
</Quotient>
|
|
636
|
+
<Quotient label="Quality" format="percent">
|
|
637
|
+
<Given label="Good tiles" value={COUNT.good} unit="tiles" />
|
|
638
|
+
<Ref to="total" />
|
|
639
|
+
</Quotient>
|
|
640
|
+
</Product>
|
|
641
|
+
</Calculation>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
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.
|
|
651
|
+
|
|
652
|
+
```tsx
|
|
653
|
+
import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref } from "@umriss-ui/calculation";
|
|
654
|
+
|
|
655
|
+
export default function Invoice() {
|
|
656
|
+
return (
|
|
657
|
+
<Calculation aria-label="Invoice INV-26-0318, Brandlow Office Supply">
|
|
658
|
+
<Chain>
|
|
659
|
+
<Product id="lamps" label="Desk lamps, LED" unit="€" decimals={2}>
|
|
660
|
+
<Given label="Lamps" value={24} unit="pcs" />
|
|
661
|
+
<Given label="Price per lamp" value={48.9} unit="€/pc" decimals={2} />
|
|
662
|
+
</Product>
|
|
663
|
+
<Minus>
|
|
664
|
+
<Product label="Discount" unit="€" decimals={2}>
|
|
665
|
+
<Given label="Discount on lamps" value={0.1} format="percent" />
|
|
666
|
+
<Ref to="lamps" />
|
|
667
|
+
</Product>
|
|
668
|
+
</Minus>
|
|
669
|
+
<Plus>
|
|
670
|
+
<Product label="Printer paper" unit="€" decimals={2}>
|
|
671
|
+
<Given label="Boxes" value={40} unit="boxes" />
|
|
672
|
+
<Given label="Price per box" value={21.5} unit="€/box" decimals={2} />
|
|
673
|
+
</Product>
|
|
674
|
+
</Plus>
|
|
675
|
+
<Plus label="Delivery" value={35} unit="€" decimals={2} />
|
|
676
|
+
<Interim id="net" label="Net amount" unit="€" decimals={2} />
|
|
677
|
+
<Plus>
|
|
678
|
+
<Product label="VAT" unit="€" decimals={2}>
|
|
679
|
+
<Given label="VAT rate" value={0.19} format="percent" />
|
|
680
|
+
<Ref to="net" />
|
|
681
|
+
</Product>
|
|
682
|
+
</Plus>
|
|
683
|
+
<Interim label="Invoice total" unit="€" decimals={2} />
|
|
684
|
+
</Chain>
|
|
685
|
+
</Calculation>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
```
|
|
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
|
+
|
|
702
|
+
#### API
|
|
703
|
+
|
|
704
|
+
##### `CalculationProps`
|
|
705
|
+
|
|
706
|
+
| Prop | Type | Default | Description |
|
|
707
|
+
|---|---|---|---|
|
|
708
|
+
| `children` (required) | `ReactNode` | — | The result: exactly one quantity - a tree (`<Sum>`, `<Difference>`, `<Product>`, `<Quotient>` with their operands, `<Given>` as leaves) or a `<Chain>`, the two mixing freely; `<Ref>` for a quantity defined elsewhere. |
|
|
709
|
+
|
|
710
|
+
Also takes every attribute of the rendered element – without `children`.
|
|
711
|
+
|
|
712
|
+
#### Known limits
|
|
713
|
+
|
|
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.
|
|
716
|
+
|
|
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).
|
|
718
|
+
|
|
719
|
+
### Tree
|
|
720
|
+
|
|
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.
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
import { Sum, Difference, Product, Quotient, Ref } from "@umriss-ui/calculation";
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/tree
|
|
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
|
+
|
|
731
|
+
#### Examples
|
|
732
|
+
|
|
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.
|
|
736
|
+
|
|
737
|
+
```tsx
|
|
738
|
+
import { Calculation, Given, Quotient } from "@umriss-ui/calculation";
|
|
739
|
+
|
|
740
|
+
export default function ShareWithTarget() {
|
|
741
|
+
return (
|
|
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" />
|
|
746
|
+
</Quotient>
|
|
747
|
+
</Calculation>
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
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.
|
|
755
|
+
|
|
756
|
+
```tsx
|
|
757
|
+
import { Calculation, Difference, Given } from "@umriss-ui/calculation";
|
|
758
|
+
|
|
759
|
+
export default function DifferenceOfThree() {
|
|
760
|
+
return (
|
|
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" />
|
|
767
|
+
</Difference>
|
|
768
|
+
</Calculation>
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
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.
|
|
776
|
+
|
|
777
|
+
```tsx
|
|
778
|
+
import { Calculation, Given, Product } from "@umriss-ui/calculation";
|
|
779
|
+
|
|
780
|
+
export default function ProductWithUnits() {
|
|
781
|
+
return (
|
|
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} />
|
|
787
|
+
</Product>
|
|
788
|
+
</Calculation>
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
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.
|
|
796
|
+
|
|
797
|
+
```tsx
|
|
798
|
+
import { Calculation, Difference, Given, Quotient, Ref } from "@umriss-ui/calculation";
|
|
799
|
+
|
|
800
|
+
export default function UsedTwice() {
|
|
801
|
+
return (
|
|
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" />
|
|
809
|
+
</Quotient>
|
|
810
|
+
</Calculation>
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
##### Build operands from data
|
|
816
|
+
|
|
817
|
+
`.map` inside an operator works; folded, a line with more than four operands says how many it holds instead of a long formula.
|
|
818
|
+
|
|
819
|
+
```tsx
|
|
820
|
+
import { DOWNTIME_MINUTES, MONTH_MINUTES, SERVICES } from "./operations";
|
|
821
|
+
import { Calculation, Difference, Given, Product, Quotient } from "@umriss-ui/calculation";
|
|
822
|
+
|
|
823
|
+
/* The services a booking passes through, in order. */
|
|
824
|
+
const PATH = ["sign-in", "search", "checkout", "billing", "notifications"];
|
|
825
|
+
|
|
826
|
+
export default function FromData() {
|
|
827
|
+
return (
|
|
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" />
|
|
837
|
+
</Quotient>
|
|
838
|
+
))}
|
|
839
|
+
</Product>
|
|
840
|
+
</Calculation>
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
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.
|
|
850
|
+
|
|
851
|
+
```tsx
|
|
852
|
+
import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
|
|
853
|
+
|
|
854
|
+
export default function Limits() {
|
|
855
|
+
return (
|
|
856
|
+
<Calculation aria-label="Cost per stop, tour T-03">
|
|
857
|
+
<Quotient
|
|
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
|
+
]}
|
|
865
|
+
>
|
|
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} />
|
|
870
|
+
</Sum>
|
|
871
|
+
<Given label="Stops" value={5} unit="stops" />
|
|
872
|
+
</Quotient>
|
|
873
|
+
</Calculation>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
#### When to use something else
|
|
879
|
+
|
|
880
|
+
- A sheet read top to bottom, line by line → Chain
|
|
881
|
+
|
|
882
|
+
#### API
|
|
883
|
+
|
|
884
|
+
##### `OperatorProps`
|
|
885
|
+
|
|
886
|
+
| Prop | Type | Default | Description |
|
|
887
|
+
|---|---|---|---|
|
|
888
|
+
| `children` | `ReactNode` | — | The operands, in order: quantities, givens and references. |
|
|
889
|
+
| `label` (required) | `string` | — | What the quantity is called. It stands on its line, wherever it is an operand, and in the accessible sentence. From `QuantityProps`. |
|
|
890
|
+
| `id` | `string` | — | Makes the quantity referable: `<Ref to>` stands for it elsewhere. Needed only for a quantity used more than once. From `QuantityProps`. |
|
|
891
|
+
| `unit` | `string` | — | A label written after the number - "min", "pcs", "€". Never converted and never checked; the calculation does no unit algebra. From `QuantityProps`. |
|
|
892
|
+
| `format` | `"percent"` | — | `percent` presents a plain ratio as per cent: 0.916 is shown as 91.6 %. The quantity stays a ratio in every operation. From `QuantityProps`. |
|
|
893
|
+
| `decimals` | `number` | — | Fixed number of fraction digits as shown. Rounding happens only in presentation; every operation uses the full value. Without it: one digit for a percentage, at most two for a derived number, and a given as it was given. From `QuantityProps`. |
|
|
894
|
+
| `target` | `number` | — | The value this quantity is meant to reach - missed, never violated. From `QuantityProps`. |
|
|
895
|
+
| `limits` | `readonly Limit[]` | — | Bounds the quantity is assessed against, through core's `assess()`. From `QuantityProps`. |
|
|
896
|
+
| `explanation` | `string` | — | A sentence of plain text beside the line: why the quantity is here, or what it means. From `QuantityProps`. |
|
|
897
|
+
| `aside` | `ReactNode` | — | Content of the caller's own beside the line - a sparkline, a link. It never takes part in the calculation. From `QuantityProps`. |
|
|
898
|
+
|
|
899
|
+
##### `RefProps`
|
|
900
|
+
|
|
901
|
+
| Prop | Type | Default | Description |
|
|
902
|
+
|---|---|---|---|
|
|
903
|
+
| `to` (required) | `string` | — | The `id` of the quantity this reference stands for. |
|
|
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
|
+
|
|
912
|
+
### Chain
|
|
913
|
+
|
|
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.
|
|
915
|
+
|
|
916
|
+
```ts
|
|
917
|
+
import { Chain, Plus, Minus, Times, DividedBy, Interim } from "@umriss-ui/calculation";
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/chain
|
|
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
|
+
|
|
926
|
+
#### Examples
|
|
927
|
+
|
|
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.
|
|
931
|
+
|
|
932
|
+
```tsx
|
|
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
|
|
999
|
+
|
|
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`.
|
|
1001
|
+
|
|
1002
|
+
```tsx
|
|
1003
|
+
import { Calculation, Chain, Given, Interim, Minus, Plus, Product, Ref, Sum } from "@umriss-ui/calculation";
|
|
1004
|
+
|
|
1005
|
+
const CONTRIBUTIONS = [
|
|
1006
|
+
{ name: "Pension insurance", rate: 0.093 },
|
|
1007
|
+
{ name: "Unemployment insurance", rate: 0.013 },
|
|
1008
|
+
{ name: "Health insurance", rate: 0.082 },
|
|
1009
|
+
{ name: "Long-term care insurance", rate: 0.017 },
|
|
1010
|
+
];
|
|
1011
|
+
|
|
1012
|
+
export default function Payslip() {
|
|
1013
|
+
return (
|
|
1014
|
+
<Calculation aria-label="Payslip, March">
|
|
1015
|
+
<Chain>
|
|
1016
|
+
<Given id="gross" label="Gross salary" value={4200} unit="€" decimals={2} />
|
|
1017
|
+
<Minus label="Income tax" value={612.5} unit="€" decimals={2} />
|
|
1018
|
+
<Minus label="Church tax" value={49} unit="€" decimals={2} />
|
|
1019
|
+
<Minus>
|
|
1020
|
+
<Sum label="Social security contributions" unit="€" decimals={2}>
|
|
1021
|
+
{CONTRIBUTIONS.map((contribution) => (
|
|
1022
|
+
<Product key={contribution.name} label={contribution.name} unit="€" decimals={2}>
|
|
1023
|
+
<Given label={`Rate, ${contribution.name.toLowerCase()}`} value={contribution.rate} format="percent" />
|
|
1024
|
+
<Ref to="gross" />
|
|
1025
|
+
</Product>
|
|
1026
|
+
))}
|
|
1027
|
+
</Sum>
|
|
1028
|
+
</Minus>
|
|
1029
|
+
<Interim label="Net salary" unit="€" decimals={2} />
|
|
1030
|
+
<Minus label="Capital-forming benefits" value={40} unit="€" decimals={2} />
|
|
1031
|
+
<Plus label="Travel allowance" value={60} unit="€" decimals={2} />
|
|
1032
|
+
<Interim label="Amount paid out" unit="€" decimals={2} />
|
|
1033
|
+
</Chain>
|
|
1034
|
+
</Calculation>
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
##### Build the lines from data
|
|
1040
|
+
|
|
1041
|
+
`.map` inside a chain makes each item a `Plus`, and all stand in view above their interim.
|
|
1042
|
+
|
|
1043
|
+
```tsx
|
|
1044
|
+
import { Calculation, Chain, Given, Interim, Plus } from "@umriss-ui/calculation";
|
|
1045
|
+
|
|
1046
|
+
const ITEMS = [
|
|
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],
|
|
1062
|
+
] as const;
|
|
1063
|
+
|
|
1064
|
+
export default function FifteenItems() {
|
|
1065
|
+
const [[firstName, firstAmount], ...rest] = ITEMS;
|
|
1066
|
+
return (
|
|
1067
|
+
<Calculation aria-label="Cloud bill, March">
|
|
1068
|
+
<Chain>
|
|
1069
|
+
<Given label={firstName} value={firstAmount} unit="€" decimals={2} />
|
|
1070
|
+
{rest.map(([name, amount]) => (
|
|
1071
|
+
<Plus key={name} label={name} value={amount} unit="€" decimals={2} />
|
|
1072
|
+
))}
|
|
1073
|
+
<Interim label="Cloud bill, March" unit="€" decimals={2} />
|
|
1074
|
+
</Chain>
|
|
1075
|
+
</Calculation>
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
##### Put a chain inside a tree
|
|
1081
|
+
|
|
1082
|
+
As an operand a chain folds and stands for its last interim; opened, it stands whole beneath its line.
|
|
1083
|
+
|
|
1084
|
+
```tsx
|
|
1085
|
+
import { Calculation, Chain, Given, Interim, Minus, Plus, Quotient, Ref } from "@umriss-ui/calculation";
|
|
1086
|
+
|
|
1087
|
+
export default function ChainInATree() {
|
|
1088
|
+
return (
|
|
1089
|
+
<Calculation aria-label="Delivery rate, North depot, Tuesday">
|
|
1090
|
+
<Quotient label="Delivery rate" format="percent" target={0.97}>
|
|
1091
|
+
<Chain>
|
|
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" />
|
|
1097
|
+
</Chain>
|
|
1098
|
+
<Ref to="out" />
|
|
1099
|
+
</Quotient>
|
|
1100
|
+
</Calculation>
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
```
|
|
1104
|
+
|
|
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.
|
|
1108
|
+
|
|
1109
|
+
```tsx
|
|
1110
|
+
import { Calculation, Chain, Given, Interim, Minus, Product } from "@umriss-ui/calculation";
|
|
1111
|
+
|
|
1112
|
+
export default function ErrorBudget() {
|
|
1113
|
+
return (
|
|
1114
|
+
<Calculation aria-label="Error budget of Checkout, March">
|
|
1115
|
+
<Chain>
|
|
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} />
|
|
1123
|
+
<Interim
|
|
1124
|
+
label="Budget left"
|
|
1125
|
+
unit="min"
|
|
1126
|
+
decimals={1}
|
|
1127
|
+
limits={[
|
|
1128
|
+
{ value: 5, side: "lower", severity: "warning" },
|
|
1129
|
+
{ value: 0, side: "lower", severity: "alarm" },
|
|
1130
|
+
]}
|
|
1131
|
+
/>
|
|
1132
|
+
</Chain>
|
|
1133
|
+
</Calculation>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
#### When to use something else
|
|
1139
|
+
|
|
1140
|
+
- A figure made of factors, folded to its formula → Tree
|
|
1141
|
+
|
|
1142
|
+
#### API
|
|
1143
|
+
|
|
1144
|
+
##### `ChainProps`
|
|
1145
|
+
|
|
1146
|
+
| Prop | Type | Default | Description |
|
|
1147
|
+
|---|---|---|---|
|
|
1148
|
+
| `children` | `ReactNode` | — | The operands, top to bottom: a first quantity with no operator, then `Plus`, `Minus`, `Times` and `DividedBy` with their operands and `Interim`s naming the value where they stand. The last line is an `Interim`; a `Times` or `DividedBy` stands alone between two named values. |
|
|
1149
|
+
|
|
1150
|
+
##### `ChainOperandProps`
|
|
1151
|
+
|
|
1152
|
+
| Prop | Type | Default | Description |
|
|
1153
|
+
|---|---|---|---|
|
|
1154
|
+
| `label` | `string` | — | The operand's name, where the line is a given written in place. Left out where the line holds a quantity as its child. |
|
|
1155
|
+
| `value` | `number \| null` | — | The operand's number, where the line is a given written in place. Absent as for a `Given`: the line and every interim after it are absent, with the reason. |
|
|
1156
|
+
| `children` | `ReactNode` | — | Instead of `label` and `value`: exactly one quantity - a tree, a `<Ref>` or a `<Chain>`. |
|
|
1157
|
+
| `source` | `string` | — | Where the number came from: a system, a report, a person. From `GivenProps`. |
|
|
1158
|
+
| `asOf` | `Date \| number` | — | When the number was true - not when it was fetched. From `GivenProps`. |
|
|
1159
|
+
| `ages` | `FreshnessAges` | — | The ages at which `asOf` turns stale and lost. With both, the given carries a freshness through core; with `asOf` alone, only the time. From `GivenProps`. |
|
|
1160
|
+
| `id` | `string` | — | Makes the quantity referable: `<Ref to>` stands for it elsewhere. Needed only for a quantity used more than once. From `QuantityProps`. |
|
|
1161
|
+
| `unit` | `string` | — | A label written after the number - "min", "pcs", "€". Never converted and never checked; the calculation does no unit algebra. From `QuantityProps`. |
|
|
1162
|
+
| `format` | `"percent"` | — | `percent` presents a plain ratio as per cent: 0.916 is shown as 91.6 %. The quantity stays a ratio in every operation. From `QuantityProps`. |
|
|
1163
|
+
| `decimals` | `number` | — | Fixed number of fraction digits as shown. Rounding happens only in presentation; every operation uses the full value. Without it: one digit for a percentage, at most two for a derived number, and a given as it was given. From `QuantityProps`. |
|
|
1164
|
+
| `target` | `number` | — | The value this quantity is meant to reach - missed, never violated. From `QuantityProps`. |
|
|
1165
|
+
| `limits` | `readonly Limit[]` | — | Bounds the quantity is assessed against, through core's `assess()`. From `QuantityProps`. |
|
|
1166
|
+
| `explanation` | `string` | — | A sentence of plain text beside the line: why the quantity is here, or what it means. From `QuantityProps`. |
|
|
1167
|
+
| `aside` | `ReactNode` | — | Content of the caller's own beside the line - a sparkline, a link. It never takes part in the calculation. From `QuantityProps`. |
|
|
1168
|
+
|
|
1169
|
+
##### `QuantityProps`
|
|
1170
|
+
|
|
1171
|
+
| Prop | Type | Default | Description |
|
|
1172
|
+
|---|---|---|---|
|
|
1173
|
+
| `label` (required) | `string` | — | What the quantity is called. It stands on its line, wherever it is an operand, and in the accessible sentence. |
|
|
1174
|
+
| `id` | `string` | — | Makes the quantity referable: `<Ref to>` stands for it elsewhere. Needed only for a quantity used more than once. |
|
|
1175
|
+
| `unit` | `string` | — | A label written after the number - "min", "pcs", "€". Never converted and never checked; the calculation does no unit algebra. |
|
|
1176
|
+
| `format` | `"percent"` | — | `percent` presents a plain ratio as per cent: 0.916 is shown as 91.6 %. The quantity stays a ratio in every operation. |
|
|
1177
|
+
| `decimals` | `number` | — | Fixed number of fraction digits as shown. Rounding happens only in presentation; every operation uses the full value. Without it: one digit for a percentage, at most two for a derived number, and a given as it was given. |
|
|
1178
|
+
| `target` | `number` | — | The value this quantity is meant to reach - missed, never violated. |
|
|
1179
|
+
| `limits` | `readonly Limit[]` | — | Bounds the quantity is assessed against, through core's `assess()`. |
|
|
1180
|
+
| `explanation` | `string` | — | A sentence of plain text beside the line: why the quantity is here, or what it means. |
|
|
1181
|
+
| `aside` | `ReactNode` | — | Content of the caller's own beside the line - a sparkline, a link. It never takes part in the calculation. |
|
|
1182
|
+
|
|
1183
|
+
#### Known limits
|
|
1184
|
+
|
|
1185
|
+
- A chain in view does not fold; as an operand of a tree it folds to its last interim.
|
|
1186
|
+
|
|
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).
|
|
1188
|
+
|
|
1189
|
+
### Given
|
|
1190
|
+
|
|
1191
|
+
The numbers a calculation starts from (inputs, leaves): where they came from, when they were true, and whether they are still fresh.
|
|
1192
|
+
|
|
1193
|
+
```ts
|
|
1194
|
+
import { Given } from "@umriss-ui/calculation";
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/given
|
|
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
|
+
|
|
1203
|
+
#### Examples
|
|
1204
|
+
|
|
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.
|
|
1208
|
+
|
|
1209
|
+
```tsx
|
|
1210
|
+
import { useState } from "react";
|
|
1211
|
+
import { Sparkline } from "@umriss-ui/core";
|
|
1212
|
+
import { Calculation, Given, Quotient } from "@umriss-ui/calculation";
|
|
1213
|
+
|
|
1214
|
+
const AGES = { stale: 15 * 60_000, lost: 60 * 60_000 };
|
|
1215
|
+
|
|
1216
|
+
export default function WhereItCameFrom() {
|
|
1217
|
+
const [now] = useState(() => Date.now());
|
|
1218
|
+
return (
|
|
1219
|
+
<Calculation aria-label="Error rate of Checkout, last five minutes">
|
|
1220
|
+
<Quotient
|
|
1221
|
+
label="Error rate, Checkout"
|
|
1222
|
+
format="percent"
|
|
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" />}
|
|
1227
|
+
>
|
|
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} />
|
|
1230
|
+
</Quotient>
|
|
1231
|
+
</Calculation>
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
```
|
|
1235
|
+
|
|
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.
|
|
1239
|
+
|
|
1240
|
+
```tsx
|
|
1241
|
+
import { Calculation, Given, Product } from "@umriss-ui/calculation";
|
|
1242
|
+
|
|
1243
|
+
export default function AsOf() {
|
|
1244
|
+
return (
|
|
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)} />
|
|
1249
|
+
</Product>
|
|
1250
|
+
</Calculation>
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
```
|
|
1254
|
+
|
|
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.
|
|
1258
|
+
|
|
1259
|
+
```tsx
|
|
1260
|
+
import { LanguageProvider } from "@umriss-ui/core";
|
|
1261
|
+
import { GERMAN_FORMATS, GERMAN_WORDING } from "@umriss-ui/core/wording/de";
|
|
1262
|
+
import { Calculation, Chain, Given, Interim, Minus, Product, Ref } from "@umriss-ui/calculation";
|
|
1263
|
+
|
|
1264
|
+
export default function InGerman() {
|
|
1265
|
+
return (
|
|
1266
|
+
<LanguageProvider wording={GERMAN_WORDING} formats={GERMAN_FORMATS}>
|
|
1267
|
+
<Calculation aria-label="Rechnung INV-26-0317, Messestand">
|
|
1268
|
+
<Chain>
|
|
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" />
|
|
1274
|
+
</Product>
|
|
1275
|
+
</Minus>
|
|
1276
|
+
<Interim label="Nettobetrag" unit="€" decimals={2} />
|
|
1277
|
+
</Chain>
|
|
1278
|
+
</Calculation>
|
|
1279
|
+
</LanguageProvider>
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
#### API
|
|
1285
|
+
|
|
1286
|
+
##### `GivenProps`
|
|
1287
|
+
|
|
1288
|
+
| Prop | Type | Default | Description |
|
|
1289
|
+
|---|---|---|---|
|
|
1290
|
+
| `value` (required) | `number \| null \| undefined` | — | The number the calculation starts from. Absent (`null`, `undefined`, not finite) makes every quantity that depends on it absent, with the reason - never zero. |
|
|
1291
|
+
| `source` | `string` | — | Where the number came from: a system, a report, a person. |
|
|
1292
|
+
| `asOf` | `Date \| number` | — | When the number was true - not when it was fetched. |
|
|
1293
|
+
| `ages` | `FreshnessAges` | — | The ages at which `asOf` turns stale and lost. With both, the given carries a freshness through core; with `asOf` alone, only the time. |
|
|
1294
|
+
| `label` (required) | `string` | — | What the quantity is called. It stands on its line, wherever it is an operand, and in the accessible sentence. From `QuantityProps`. |
|
|
1295
|
+
| `id` | `string` | — | Makes the quantity referable: `<Ref to>` stands for it elsewhere. Needed only for a quantity used more than once. From `QuantityProps`. |
|
|
1296
|
+
| `unit` | `string` | — | A label written after the number - "min", "pcs", "€". Never converted and never checked; the calculation does no unit algebra. From `QuantityProps`. |
|
|
1297
|
+
| `format` | `"percent"` | — | `percent` presents a plain ratio as per cent: 0.916 is shown as 91.6 %. The quantity stays a ratio in every operation. From `QuantityProps`. |
|
|
1298
|
+
| `decimals` | `number` | — | Fixed number of fraction digits as shown. Rounding happens only in presentation; every operation uses the full value. Without it: one digit for a percentage, at most two for a derived number, and a given as it was given. From `QuantityProps`. |
|
|
1299
|
+
| `target` | `number` | — | The value this quantity is meant to reach - missed, never violated. From `QuantityProps`. |
|
|
1300
|
+
| `limits` | `readonly Limit[]` | — | Bounds the quantity is assessed against, through core's `assess()`. From `QuantityProps`. |
|
|
1301
|
+
| `explanation` | `string` | — | A sentence of plain text beside the line: why the quantity is here, or what it means. From `QuantityProps`. |
|
|
1302
|
+
| `aside` | `ReactNode` | — | Content of the caller's own beside the line - a sparkline, a link. It never takes part in the calculation. From `QuantityProps`. |
|
|
1303
|
+
|
|
1304
|
+
## In practice
|
|
1305
|
+
|
|
1306
|
+
What a calculation does when the numbers are not what they should be, and whole figures worked in full.
|
|
1307
|
+
|
|
1308
|
+
### What can go wrong
|
|
1309
|
+
|
|
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.
|
|
1311
|
+
|
|
1312
|
+
```ts
|
|
1313
|
+
import { Calculation } from "@umriss-ui/calculation";
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/what-can-go-wrong
|
|
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
|
+
|
|
1320
|
+
#### Examples
|
|
1321
|
+
|
|
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.
|
|
1325
|
+
|
|
1326
|
+
```tsx
|
|
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
|
+
];
|
|
1336
|
+
|
|
1337
|
+
export default function Missing() {
|
|
1338
|
+
return (
|
|
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>
|
|
1348
|
+
</Calculation>
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
##### A division by zero
|
|
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
|
+
|
|
1357
|
+
```tsx
|
|
1358
|
+
import { Calculation, Given, Product, Quotient, Sum } from "@umriss-ui/calculation";
|
|
1359
|
+
|
|
1360
|
+
export default function DivisionByZero() {
|
|
1361
|
+
return (
|
|
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" />
|
|
1370
|
+
</Quotient>
|
|
1371
|
+
<Given label="Parcels planned for tomorrow" value={80} unit="parcels" />
|
|
1372
|
+
</Product>
|
|
1373
|
+
</Calculation>
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
```
|
|
1377
|
+
|
|
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 ≈.
|
|
1381
|
+
|
|
1382
|
+
```tsx
|
|
1383
|
+
import { Calculation, Given, Quotient, Sum } from "@umriss-ui/calculation";
|
|
1384
|
+
|
|
1385
|
+
const PEOPLE = ["Arjun Mehta", "Chloe Durand", "Eva Novak"];
|
|
1386
|
+
|
|
1387
|
+
export default function Rounding() {
|
|
1388
|
+
return (
|
|
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" />
|
|
1395
|
+
</Quotient>
|
|
1396
|
+
))}
|
|
1397
|
+
</Sum>
|
|
1398
|
+
</Calculation>
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
##### A limit violated deep inside
|
|
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
|
+
|
|
1407
|
+
```tsx
|
|
1408
|
+
import { Calculation, Chain, Given, Interim, Plus, Product } from "@umriss-ui/calculation";
|
|
1409
|
+
|
|
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
|
+
|
|
1415
|
+
export default function LimitInside() {
|
|
1416
|
+
return (
|
|
1417
|
+
<Calculation aria-label="Payments at risk, this morning">
|
|
1418
|
+
<Chain>
|
|
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} />
|
|
1422
|
+
</Product>
|
|
1423
|
+
<Plus>
|
|
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} />
|
|
1427
|
+
</Product>
|
|
1428
|
+
</Plus>
|
|
1429
|
+
<Interim label="Payments at risk" unit="€" decimals={2} />
|
|
1430
|
+
</Chain>
|
|
1431
|
+
</Calculation>
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
### Worked examples
|
|
1437
|
+
|
|
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.
|
|
1439
|
+
|
|
1440
|
+
```ts
|
|
1441
|
+
import { Calculation, Chain, Given, Ref } from "@umriss-ui/calculation";
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
Demo page: https://romanhaendler.github.io/umriss-ui/calculation/#/worked-examples
|
|
1445
|
+
|
|
1446
|
+
#### Examples
|
|
1447
|
+
|
|
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.
|
|
1451
|
+
|
|
1452
|
+
```tsx
|
|
1453
|
+
import { COST_RATES, TOURS, VEHICLES } from "./logistics";
|
|
1454
|
+
import { Calculation, Chain, DividedBy, Given, Interim, Plus, Product, Sum } from "@umriss-ui/calculation";
|
|
1455
|
+
|
|
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);
|
|
1461
|
+
|
|
1462
|
+
const AGES = { stale: 24 * 3_600_000, lost: 14 * 24 * 3_600_000 };
|
|
1463
|
+
|
|
1464
|
+
export default function CostPerTour() {
|
|
1465
|
+
return (
|
|
1466
|
+
<Calculation aria-label="Cost per stop, all tours, Tuesday">
|
|
1467
|
+
<Chain>
|
|
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} />
|
|
1472
|
+
))}
|
|
1473
|
+
</Sum>
|
|
1474
|
+
<Given label="Driver rate, all in" value={COST_RATES.driverPerHour} unit="€/h" decimals={2} />
|
|
1475
|
+
</Product>
|
|
1476
|
+
<Plus>
|
|
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>
|
|
1486
|
+
<Given
|
|
1487
|
+
label="Diesel price"
|
|
1488
|
+
value={COST_RATES.dieselPerLitre}
|
|
1489
|
+
unit="€/l"
|
|
1490
|
+
decimals={2}
|
|
1491
|
+
source="Fuel card, weekly price"
|
|
1492
|
+
asOf={new Date(2026, 2, 14, 6, 0)}
|
|
1493
|
+
ages={AGES}
|
|
1494
|
+
/>
|
|
1495
|
+
</Product>
|
|
1496
|
+
</Plus>
|
|
1497
|
+
<Plus>
|
|
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" />
|
|
1508
|
+
</Product>
|
|
1509
|
+
</Plus>
|
|
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
|
+
/>
|
|
1525
|
+
</Chain>
|
|
1526
|
+
</Calculation>
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
```
|
|
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
|
+
|
|
1612
|
+
##### OEE of a hall, weighted by planned time
|
|
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
|
+
|
|
1616
|
+
```tsx
|
|
1617
|
+
import { SHIFT_MINUTES } from "./plant";
|
|
1618
|
+
import { Calculation, Difference, Given, Product, Quotient, Ref, Sum } from "@umriss-ui/calculation";
|
|
1619
|
+
|
|
1620
|
+
interface Line {
|
|
1621
|
+
name: string;
|
|
1622
|
+
planned: number;
|
|
1623
|
+
downtime: number | null;
|
|
1624
|
+
idealCycle: number;
|
|
1625
|
+
total: number;
|
|
1626
|
+
good: number;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const BREAKS = 30;
|
|
1630
|
+
|
|
1631
|
+
const LINES: Line[] = [
|
|
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 },
|
|
1635
|
+
];
|
|
1636
|
+
|
|
1637
|
+
function lineOee(line: Line) {
|
|
1638
|
+
const n = line.name;
|
|
1639
|
+
return (
|
|
1640
|
+
<Product key={n} label={`Weighted OEE, line ${n}`} unit="min">
|
|
1641
|
+
<Product label={`OEE, line ${n}`} format="percent">
|
|
1642
|
+
<Quotient label={`Availability, line ${n}`} format="percent">
|
|
1643
|
+
<Difference id={`runtime-${n}`} label={`Run time, line ${n}`} unit="min">
|
|
1644
|
+
<Given id={`planned-${n}`} label={`Planned time, line ${n}`} value={line.planned} unit="min" />
|
|
1645
|
+
<Given label={`Downtime, line ${n}`} value={line.downtime} unit="min" source="Shift log" />
|
|
1646
|
+
</Difference>
|
|
1647
|
+
<Ref to={`planned-${n}`} />
|
|
1648
|
+
</Quotient>
|
|
1649
|
+
<Quotient label={`Performance, line ${n}`} format="percent">
|
|
1650
|
+
<Product label={`Ideal run time, line ${n}`} unit="min">
|
|
1651
|
+
<Given label={`Ideal cycle time, line ${n}`} value={line.idealCycle} unit="min/pc" />
|
|
1652
|
+
<Given id={`total-${n}`} label={`Total count, line ${n}`} value={line.total} unit="pcs" />
|
|
1653
|
+
</Product>
|
|
1654
|
+
<Ref to={`runtime-${n}`} />
|
|
1655
|
+
</Quotient>
|
|
1656
|
+
<Quotient label={`Quality, line ${n}`} format="percent">
|
|
1657
|
+
<Given label={`Good count, line ${n}`} value={line.good} unit="pcs" />
|
|
1658
|
+
<Ref to={`total-${n}`} />
|
|
1659
|
+
</Quotient>
|
|
1660
|
+
</Product>
|
|
1661
|
+
<Ref to={`planned-${n}`} />
|
|
1662
|
+
</Product>
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export default function HallOee() {
|
|
1667
|
+
return (
|
|
1668
|
+
<Calculation aria-label="OEE of hall 2, early shift">
|
|
1669
|
+
<Quotient label="OEE, hall 2" format="percent" target={0.8}>
|
|
1670
|
+
<Sum label="Weighted OEE, all lines" unit="min">
|
|
1671
|
+
{LINES.map(lineOee)}
|
|
1672
|
+
</Sum>
|
|
1673
|
+
<Sum label="Planned time, all lines" unit="min">
|
|
1674
|
+
{LINES.map((line) => (
|
|
1675
|
+
<Ref key={line.name} to={`planned-${line.name}`} />
|
|
1676
|
+
))}
|
|
1677
|
+
</Sum>
|
|
1678
|
+
</Quotient>
|
|
1679
|
+
</Calculation>
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
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
|
+
```
|