@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 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.
@@ -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.0.1-placeholder.0",
4
- "description": "Placeholder to bootstrap npm trusted publishing. Real releases are published from stella/stella via GitHub Actions.",
5
- "license": "Apache-2.0"
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
  }