@stll/calculations 0.0.1-placeholder.0 → 0.1.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/README.md +13 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +264 -0
- package/package.json +59 -3
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @stll/calculations
|
|
2
|
+
|
|
3
|
+
Typed, unit-safe reducers for Stella workspace columns.
|
|
4
|
+
|
|
5
|
+
The package provides counting, numeric and monetary reductions, including
|
|
6
|
+
view-scoped percentages. Mixed units remain unsupported instead of producing a
|
|
7
|
+
misleading total.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { runCalculation } from "@stll/calculations";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Public API changes require a changeset.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CentsAmount, MoneyTotalsEntry } from "@stll/money";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
declare const CALCULATION_KINDS: readonly ["count", "count-unique", "count-empty", "count-filled", "percent-empty", "percent-filled", "sum", "average", "median", "min", "max", "range", "percent-of-total"];
|
|
4
|
+
type CalculationKind = (typeof CALCULATION_KINDS)[number];
|
|
5
|
+
/** Kinds that reduce the values themselves rather than counting rows. */
|
|
6
|
+
declare const NUMERIC_CALCULATION_KINDS: readonly ["sum", "average", "median", "min", "max", "range", "percent-of-total"];
|
|
7
|
+
type NumericCalculationKind = (typeof NUMERIC_CALCULATION_KINDS)[number];
|
|
8
|
+
declare const isNumericCalculationKind: (kind: CalculationKind) => kind is NumericCalculationKind;
|
|
9
|
+
/**
|
|
10
|
+
* One row's contribution to a calculation.
|
|
11
|
+
*
|
|
12
|
+
* A property renderer decides which arm a cell maps to. `empty` is its own arm
|
|
13
|
+
* rather than a null value so "no value" is never confused with the number
|
|
14
|
+
* zero, which is what makes count-empty and average disagree correctly.
|
|
15
|
+
*/
|
|
16
|
+
type CalculationValue = {
|
|
17
|
+
type: "empty";
|
|
18
|
+
} | {
|
|
19
|
+
type: "number";
|
|
20
|
+
value: number;
|
|
21
|
+
} | {
|
|
22
|
+
type: "money";
|
|
23
|
+
amountCents: CentsAmount;
|
|
24
|
+
currency: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: "text";
|
|
27
|
+
value: string;
|
|
28
|
+
};
|
|
29
|
+
type CalculationUnsupportedReason =
|
|
30
|
+
/** A numeric reduction over values that are not numbers. */
|
|
31
|
+
"non-numeric" |
|
|
32
|
+
/** Numbers mixed with money, or money the reduction cannot combine. */
|
|
33
|
+
"mixed-units" |
|
|
34
|
+
/** A reduction that needs at least one value, over none. */
|
|
35
|
+
"no-values" |
|
|
36
|
+
/** A share of the whole, asked without being told what the whole is. */
|
|
37
|
+
"no-scope";
|
|
38
|
+
type CalculationResult =
|
|
39
|
+
/** A number of rows. */
|
|
40
|
+
{
|
|
41
|
+
type: "count";
|
|
42
|
+
kind: CalculationKind;
|
|
43
|
+
value: number;
|
|
44
|
+
} |
|
|
45
|
+
/** A share between 0 and 1. */
|
|
46
|
+
{
|
|
47
|
+
type: "ratio";
|
|
48
|
+
kind: CalculationKind;
|
|
49
|
+
value: number;
|
|
50
|
+
} |
|
|
51
|
+
/** A plain number in the property's own unit. */
|
|
52
|
+
{
|
|
53
|
+
type: "number";
|
|
54
|
+
kind: CalculationKind;
|
|
55
|
+
value: number;
|
|
56
|
+
} |
|
|
57
|
+
/** One amount per currency, sorted by currency code. */
|
|
58
|
+
{
|
|
59
|
+
type: "money";
|
|
60
|
+
kind: CalculationKind;
|
|
61
|
+
totals: readonly MoneyTotalsEntry[];
|
|
62
|
+
} | {
|
|
63
|
+
type: "unsupported";
|
|
64
|
+
kind: CalculationKind;
|
|
65
|
+
reason: CalculationUnsupportedReason;
|
|
66
|
+
};
|
|
67
|
+
type RunCalculationParams = {
|
|
68
|
+
kind: CalculationKind;
|
|
69
|
+
/** The values in the column, in row order. */
|
|
70
|
+
values: readonly CalculationValue[];
|
|
71
|
+
/**
|
|
72
|
+
* Every value in the view, for a reduction relative to the whole
|
|
73
|
+
* (percent-of-total). Omitting it is not "relative to itself" — that answer
|
|
74
|
+
* is always 100% and always meaningless — so percent-of-total without a
|
|
75
|
+
* scope is unsupported rather than wrong.
|
|
76
|
+
*/
|
|
77
|
+
scopeValues?: readonly CalculationValue[] | undefined;
|
|
78
|
+
};
|
|
79
|
+
declare const runCalculation: ({ kind, values, scopeValues }: RunCalculationParams) => CalculationResult;
|
|
80
|
+
//#endregion
|
|
81
|
+
export { CALCULATION_KINDS, CalculationKind, CalculationResult, CalculationUnsupportedReason, CalculationValue, type CentsAmount, type MoneyTotalsEntry, NUMERIC_CALCULATION_KINDS, NumericCalculationKind, RunCalculationParams, isNumericCalculationKind, runCalculation };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { panic } from "better-result";
|
|
2
|
+
import { MoneyTotals, cents } from "@stll/money";
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Column calculations: the reduction a view shows under (or beside) a column.
|
|
6
|
+
*
|
|
7
|
+
* The language is deliberately small and deliberately shared. A board's column
|
|
8
|
+
* header and a table's footer row must agree on what "sum" means, or the same
|
|
9
|
+
* rows read two different totals depending on which view is open; one reducer
|
|
10
|
+
* is what makes that agreement structural rather than a convention.
|
|
11
|
+
*
|
|
12
|
+
* Money never crosses currencies. A monetary reduction returns one line per
|
|
13
|
+
* currency (`MoneyTotals` buckets them), so there is no code path in which two
|
|
14
|
+
* currencies silently add up to a number that means nothing.
|
|
15
|
+
*/
|
|
16
|
+
const CALCULATION_KINDS = [
|
|
17
|
+
"count",
|
|
18
|
+
"count-unique",
|
|
19
|
+
"count-empty",
|
|
20
|
+
"count-filled",
|
|
21
|
+
"percent-empty",
|
|
22
|
+
"percent-filled",
|
|
23
|
+
"sum",
|
|
24
|
+
"average",
|
|
25
|
+
"median",
|
|
26
|
+
"min",
|
|
27
|
+
"max",
|
|
28
|
+
"range",
|
|
29
|
+
"percent-of-total"
|
|
30
|
+
];
|
|
31
|
+
/** Kinds that reduce the values themselves rather than counting rows. */
|
|
32
|
+
const NUMERIC_CALCULATION_KINDS = [
|
|
33
|
+
"sum",
|
|
34
|
+
"average",
|
|
35
|
+
"median",
|
|
36
|
+
"min",
|
|
37
|
+
"max",
|
|
38
|
+
"range",
|
|
39
|
+
"percent-of-total"
|
|
40
|
+
];
|
|
41
|
+
const isNumericCalculationKind = (kind) => NUMERIC_CALCULATION_KINDS.some((candidate) => candidate === kind);
|
|
42
|
+
const runCalculation = ({ kind, values, scopeValues }) => {
|
|
43
|
+
switch (kind) {
|
|
44
|
+
case "count": return {
|
|
45
|
+
type: "count",
|
|
46
|
+
kind,
|
|
47
|
+
value: values.length
|
|
48
|
+
};
|
|
49
|
+
case "count-empty": return {
|
|
50
|
+
type: "count",
|
|
51
|
+
kind,
|
|
52
|
+
value: countEmpty(values)
|
|
53
|
+
};
|
|
54
|
+
case "count-filled": return {
|
|
55
|
+
type: "count",
|
|
56
|
+
kind,
|
|
57
|
+
value: values.length - countEmpty(values)
|
|
58
|
+
};
|
|
59
|
+
case "count-unique": return {
|
|
60
|
+
type: "count",
|
|
61
|
+
kind,
|
|
62
|
+
value: countUnique(values)
|
|
63
|
+
};
|
|
64
|
+
case "percent-empty": return {
|
|
65
|
+
type: "ratio",
|
|
66
|
+
kind,
|
|
67
|
+
value: share(countEmpty(values), values)
|
|
68
|
+
};
|
|
69
|
+
case "percent-filled": return {
|
|
70
|
+
type: "ratio",
|
|
71
|
+
kind,
|
|
72
|
+
value: share(values.length - countEmpty(values), values)
|
|
73
|
+
};
|
|
74
|
+
case "sum":
|
|
75
|
+
case "average":
|
|
76
|
+
case "median":
|
|
77
|
+
case "min":
|
|
78
|
+
case "max":
|
|
79
|
+
case "range": return reduceNumeric(kind, values);
|
|
80
|
+
case "percent-of-total": return scopeValues === void 0 ? {
|
|
81
|
+
type: "unsupported",
|
|
82
|
+
kind,
|
|
83
|
+
reason: "no-scope"
|
|
84
|
+
} : percentOfTotal(kind, values, scopeValues);
|
|
85
|
+
default: return panic(`Unhandled kind: ${String(kind)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const countEmpty = (values) => values.filter((value) => value.type === "empty").length;
|
|
89
|
+
const share = (part, values) => values.length === 0 ? 0 : part / values.length;
|
|
90
|
+
/**
|
|
91
|
+
* Distinctness key. Money is keyed by currency and amount together, so 100 CZK
|
|
92
|
+
* and 100 EUR are two values, not one.
|
|
93
|
+
*/
|
|
94
|
+
const uniqueKey = (value) => {
|
|
95
|
+
switch (value.type) {
|
|
96
|
+
case "empty": return null;
|
|
97
|
+
case "number": return `n:${value.value}`;
|
|
98
|
+
case "money": return `m:${value.currency}:${value.amountCents}`;
|
|
99
|
+
case "text": return `t:${value.value}`;
|
|
100
|
+
default: return panic(`Unhandled value: ${String(value)}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const countUnique = (values) => {
|
|
104
|
+
const seen = /* @__PURE__ */ new Set();
|
|
105
|
+
for (const value of values) {
|
|
106
|
+
const key = uniqueKey(value);
|
|
107
|
+
if (key !== null) seen.add(key);
|
|
108
|
+
}
|
|
109
|
+
return seen.size;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Split the non-empty values into a single unit, or say why they have none.
|
|
113
|
+
* Numbers and money together are the interesting failure: each side is
|
|
114
|
+
* reducible on its own, and combining them would produce a number whose unit
|
|
115
|
+
* nobody can name.
|
|
116
|
+
*/
|
|
117
|
+
const classify = (values) => {
|
|
118
|
+
const numbers = [];
|
|
119
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
120
|
+
for (const value of values) switch (value.type) {
|
|
121
|
+
case "empty": break;
|
|
122
|
+
case "text": return {
|
|
123
|
+
type: "unsupported",
|
|
124
|
+
reason: "non-numeric"
|
|
125
|
+
};
|
|
126
|
+
case "number":
|
|
127
|
+
numbers.push(value.value);
|
|
128
|
+
break;
|
|
129
|
+
case "money": {
|
|
130
|
+
const bucket = buckets.get(value.currency);
|
|
131
|
+
if (bucket) bucket.push(value.amountCents);
|
|
132
|
+
else buckets.set(value.currency, [value.amountCents]);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
default: return panic(`Unhandled value: ${String(value)}`);
|
|
136
|
+
}
|
|
137
|
+
if (numbers.length > 0 && buckets.size > 0) return {
|
|
138
|
+
type: "unsupported",
|
|
139
|
+
reason: "mixed-units"
|
|
140
|
+
};
|
|
141
|
+
if (buckets.size > 0) return {
|
|
142
|
+
type: "money",
|
|
143
|
+
buckets
|
|
144
|
+
};
|
|
145
|
+
if (numbers.length > 0) return {
|
|
146
|
+
type: "numbers",
|
|
147
|
+
values: numbers
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
type: "unsupported",
|
|
151
|
+
reason: "no-values"
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const REDUCERS = {
|
|
155
|
+
sum: (values) => values.reduce((total, value) => total + value, 0),
|
|
156
|
+
average: (values) => values.reduce((total, value) => total + value, 0) / values.length,
|
|
157
|
+
median: (values) => {
|
|
158
|
+
const sorted = [...values].toSorted((a, b) => a - b);
|
|
159
|
+
const middle = Math.floor(sorted.length / 2);
|
|
160
|
+
const upper = sorted[middle] ?? 0;
|
|
161
|
+
if (sorted.length % 2 === 1) return upper;
|
|
162
|
+
return ((sorted[middle - 1] ?? 0) + upper) / 2;
|
|
163
|
+
},
|
|
164
|
+
min: (values) => Math.min(...values),
|
|
165
|
+
max: (values) => Math.max(...values),
|
|
166
|
+
range: (values) => Math.max(...values) - Math.min(...values)
|
|
167
|
+
};
|
|
168
|
+
const reduceNumeric = (kind, values) => {
|
|
169
|
+
const classified = classify(values);
|
|
170
|
+
switch (classified.type) {
|
|
171
|
+
case "unsupported":
|
|
172
|
+
if (kind === "sum" && classified.reason === "no-values") return {
|
|
173
|
+
type: "number",
|
|
174
|
+
kind,
|
|
175
|
+
value: 0
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
type: "unsupported",
|
|
179
|
+
kind,
|
|
180
|
+
reason: classified.reason
|
|
181
|
+
};
|
|
182
|
+
case "numbers": return {
|
|
183
|
+
type: "number",
|
|
184
|
+
kind,
|
|
185
|
+
value: REDUCERS[kind](classified.values)
|
|
186
|
+
};
|
|
187
|
+
case "money": {
|
|
188
|
+
const totals = new MoneyTotals();
|
|
189
|
+
for (const [currency, amounts] of classified.buckets) totals.add(currency, cents(Math.round(REDUCERS[kind](amounts))));
|
|
190
|
+
return {
|
|
191
|
+
type: "money",
|
|
192
|
+
kind,
|
|
193
|
+
totals: totals.entries()
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
default: return panic(`Unhandled classified: ${String(classified)}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* The column's total as a share of the view's total.
|
|
201
|
+
*
|
|
202
|
+
* A ratio only means something when both totals are counted in the same thing,
|
|
203
|
+
* so each side carries its unit out of `singleTotal` and a mismatch is rejected
|
|
204
|
+
* rather than divided: 100 EUR of a 100 CZK whole is not "all of it", and five
|
|
205
|
+
* of a hundred euros is not five percent.
|
|
206
|
+
*/
|
|
207
|
+
const percentOfTotal = (kind, values, scopeValues) => {
|
|
208
|
+
const part = singleTotal(values);
|
|
209
|
+
const whole = singleTotal(scopeValues);
|
|
210
|
+
if (whole.type === "unsupported") return {
|
|
211
|
+
type: "unsupported",
|
|
212
|
+
kind,
|
|
213
|
+
reason: whole.reason
|
|
214
|
+
};
|
|
215
|
+
if (part.type === "unsupported" && part.reason !== "no-values") return {
|
|
216
|
+
type: "unsupported",
|
|
217
|
+
kind,
|
|
218
|
+
reason: part.reason
|
|
219
|
+
};
|
|
220
|
+
if (part.type === "total" && part.unit !== whole.unit) return {
|
|
221
|
+
type: "unsupported",
|
|
222
|
+
kind,
|
|
223
|
+
reason: "mixed-units"
|
|
224
|
+
};
|
|
225
|
+
if (whole.value === 0) return {
|
|
226
|
+
type: "unsupported",
|
|
227
|
+
kind,
|
|
228
|
+
reason: "no-values"
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
type: "ratio",
|
|
232
|
+
kind,
|
|
233
|
+
value: (part.type === "unsupported" ? 0 : part.value) / whole.value
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
const singleTotal = (values) => {
|
|
237
|
+
const classified = classify(values);
|
|
238
|
+
switch (classified.type) {
|
|
239
|
+
case "unsupported": return {
|
|
240
|
+
type: "unsupported",
|
|
241
|
+
reason: classified.reason
|
|
242
|
+
};
|
|
243
|
+
case "numbers": return {
|
|
244
|
+
type: "total",
|
|
245
|
+
value: REDUCERS.sum(classified.values),
|
|
246
|
+
unit: "number"
|
|
247
|
+
};
|
|
248
|
+
case "money": {
|
|
249
|
+
if (classified.buckets.size > 1) return {
|
|
250
|
+
type: "unsupported",
|
|
251
|
+
reason: "mixed-units"
|
|
252
|
+
};
|
|
253
|
+
const [currency, amounts] = [...classified.buckets.entries()].at(0) ?? ["", []];
|
|
254
|
+
return {
|
|
255
|
+
type: "total",
|
|
256
|
+
value: REDUCERS.sum(amounts),
|
|
257
|
+
unit: currency
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
default: return panic(`Unhandled classified: ${String(classified)}`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
export { CALCULATION_KINDS, NUMERIC_CALCULATION_KINDS, isNumericCalculationKind, runCalculation };
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/calculations",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Typed, unit-safe reducers for workspace column calculations.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"calculations",
|
|
7
|
+
"columns",
|
|
8
|
+
"money",
|
|
9
|
+
"reductions",
|
|
10
|
+
"workspace"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/stella/stella/tree/main/packages/calculations",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/stella/stella/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/stella/stella.git",
|
|
20
|
+
"directory": "packages/calculations"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"clean": "git clean -xdf dist .cache .turbo node_modules",
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"pack:dry-run": "bun pm pack --dry-run",
|
|
41
|
+
"test": "bun test src",
|
|
42
|
+
"test:property": "bun test --preload @stll/property-testing/preload $(grep -rlF 'fc.assert' src)",
|
|
43
|
+
"typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
|
|
44
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/calculations",
|
|
45
|
+
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/calculations",
|
|
46
|
+
"format": "bun ../../scripts/run-oxfmt.ts .",
|
|
47
|
+
"prepack": "bun run build"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@stll/money": "^0.2.0",
|
|
51
|
+
"better-result": "3.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@stll/property-testing": "0.0.0",
|
|
55
|
+
"@stll/typescript-config": "0.0.0",
|
|
56
|
+
"bun-types": "1.4.2",
|
|
57
|
+
"fast-check": "^4.9.0",
|
|
58
|
+
"tsdown": "0.22.14"
|
|
59
|
+
},
|
|
60
|
+
"main": "./dist/index.js",
|
|
61
|
+
"types": "./dist/index.d.ts"
|
|
6
62
|
}
|