@columenlabs/lumen-ui-types 0.1.34 → 0.1.35

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.
@@ -0,0 +1,14 @@
1
+ export interface FormulaFunction {
2
+ name: string;
3
+ /** How it is called, e.g. `left(text, n)`. */
4
+ call: string;
5
+ /** What it gives, in a few words. */
6
+ detail: string;
7
+ group: 'Date' | 'Text' | 'Number';
8
+ /** It gives text (a column of it shows as Text). */
9
+ text?: boolean;
10
+ fn: (...args: unknown[]) => unknown;
11
+ }
12
+ export declare const FORMULA_FUNCTIONS: FormulaFunction[];
13
+ /** The functions as one line each, for an assistant's instructions. */
14
+ export declare const formulaFunctionsText: () => string;
@@ -0,0 +1,269 @@
1
+ // The functions a view formula can call beyond group_sum and total_sum: dates, text and numbers,
2
+ // e.g. `year(transactions.date)`, `left(symbol.name, 3)`, `round(holdings.weight, 1)`. Dates are
3
+ // `YYYY-MM-DD` or ISO text (or epoch milliseconds), read in UTC so the browser and the server agree;
4
+ // date math is date-fns. A function given what it cannot use gives no value (a blank cell).
5
+ import { UTCDate, utc } from '@date-fns/utc';
6
+ import { differenceInCalendarDays, format, getDate, getISODay, getMonth, getQuarter, getYear, isValid, parseISO, startOfISOWeek, startOfMonth, startOfQuarter, startOfYear, } from 'date-fns';
7
+ const asDate = (v) => {
8
+ const d = typeof v === 'number'
9
+ ? new UTCDate(v)
10
+ : typeof v === 'string' && v
11
+ ? parseISO(v, { in: utc })
12
+ : null;
13
+ return d && isValid(d) ? d : null;
14
+ };
15
+ const asText = (v) => typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : null;
16
+ const asNum = (v) => {
17
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : Number.NaN;
18
+ return Number.isFinite(n) ? n : null;
19
+ };
20
+ const day = (d) => format(d, 'yyyy-MM-dd');
21
+ /** A date part: the function reads one date and gives a number. */
22
+ const part = (get) => (v) => {
23
+ const d = asDate(v);
24
+ return d ? get(d) : undefined;
25
+ };
26
+ /** A text function: the first argument is the text, the rest numbers (or text, `rest`). */
27
+ const onText = (f) => (v, ...rest) => {
28
+ const s = asText(v);
29
+ return s === null ? undefined : (f(s, ...rest) ?? undefined);
30
+ };
31
+ const STARTS = {
32
+ week: startOfISOWeek,
33
+ month: startOfMonth,
34
+ quarter: startOfQuarter,
35
+ year: startOfYear,
36
+ };
37
+ export const FORMULA_FUNCTIONS = [
38
+ {
39
+ name: 'year',
40
+ call: 'year(date)',
41
+ detail: 'the year, 2026',
42
+ group: 'Date',
43
+ fn: part(getYear),
44
+ },
45
+ {
46
+ name: 'month',
47
+ call: 'month(date)',
48
+ detail: 'the month, 1 to 12',
49
+ group: 'Date',
50
+ fn: part((d) => getMonth(d) + 1),
51
+ },
52
+ {
53
+ name: 'day',
54
+ call: 'day(date)',
55
+ detail: 'the day of the month',
56
+ group: 'Date',
57
+ fn: part(getDate),
58
+ },
59
+ {
60
+ name: 'quarter',
61
+ call: 'quarter(date)',
62
+ detail: 'the quarter, 1 to 4',
63
+ group: 'Date',
64
+ fn: part(getQuarter),
65
+ },
66
+ {
67
+ name: 'weekday',
68
+ call: 'weekday(date)',
69
+ detail: 'the day of the week, 1 Monday to 7 Sunday',
70
+ group: 'Date',
71
+ fn: part(getISODay),
72
+ },
73
+ {
74
+ name: 'days_between',
75
+ call: 'days_between(from, to)',
76
+ detail: 'days from one date to another',
77
+ group: 'Date',
78
+ fn: (a, b) => {
79
+ const from = asDate(a);
80
+ const to = asDate(b);
81
+ return from && to ? differenceInCalendarDays(to, from) : undefined;
82
+ },
83
+ },
84
+ {
85
+ name: 'start_of',
86
+ call: "start_of(date, 'month')",
87
+ detail: "the first day of its 'week', 'month', 'quarter' or 'year'",
88
+ group: 'Date',
89
+ text: true,
90
+ fn: (v, unit) => {
91
+ const d = asDate(v);
92
+ const start = typeof unit === 'string' ? STARTS[unit] : undefined;
93
+ return d && start ? day(start(d)) : undefined;
94
+ },
95
+ },
96
+ {
97
+ name: 'today',
98
+ call: 'today()',
99
+ detail: "today's date (UTC)",
100
+ group: 'Date',
101
+ text: true,
102
+ fn: () => day(new UTCDate()),
103
+ },
104
+ {
105
+ name: 'left',
106
+ call: 'left(text, n)',
107
+ detail: 'the first n characters',
108
+ group: 'Text',
109
+ text: true,
110
+ fn: onText((s, n) => {
111
+ const k = asNum(n);
112
+ return k === null ? null : s.slice(0, Math.max(0, k));
113
+ }),
114
+ },
115
+ {
116
+ name: 'right',
117
+ call: 'right(text, n)',
118
+ detail: 'the last n characters',
119
+ group: 'Text',
120
+ text: true,
121
+ fn: onText((s, n) => {
122
+ const k = asNum(n);
123
+ return k === null ? null : k <= 0 ? '' : s.slice(-k);
124
+ }),
125
+ },
126
+ {
127
+ name: 'mid',
128
+ call: 'mid(text, start, length)',
129
+ detail: 'length characters from position start (the first is 1)',
130
+ group: 'Text',
131
+ text: true,
132
+ fn: onText((s, start, length) => {
133
+ const a = asNum(start);
134
+ const n = asNum(length);
135
+ return a === null || n === null ? null : s.slice(Math.max(0, a - 1), Math.max(0, a - 1 + n));
136
+ }),
137
+ },
138
+ {
139
+ name: 'slice',
140
+ call: 'slice(text, from, to)',
141
+ detail: 'characters from index from up to to, the first is 0 (to left out: to the end)',
142
+ group: 'Text',
143
+ text: true,
144
+ fn: onText((s, from, to) => {
145
+ const a = asNum(from);
146
+ if (a === null)
147
+ return null;
148
+ return to === undefined ? s.slice(a) : s.slice(a, asNum(to) ?? undefined);
149
+ }),
150
+ },
151
+ {
152
+ name: 'upper',
153
+ call: 'upper(text)',
154
+ detail: 'in capitals',
155
+ group: 'Text',
156
+ text: true,
157
+ fn: onText((s) => s.toUpperCase()),
158
+ },
159
+ {
160
+ name: 'lower',
161
+ call: 'lower(text)',
162
+ detail: 'in small letters',
163
+ group: 'Text',
164
+ text: true,
165
+ fn: onText((s) => s.toLowerCase()),
166
+ },
167
+ {
168
+ name: 'trim',
169
+ call: 'trim(text)',
170
+ detail: 'without spaces at either end',
171
+ group: 'Text',
172
+ text: true,
173
+ fn: onText((s) => s.trim()),
174
+ },
175
+ {
176
+ name: 'len',
177
+ call: 'len(text)',
178
+ detail: 'how many characters',
179
+ group: 'Text',
180
+ fn: onText((s) => s.length),
181
+ },
182
+ {
183
+ name: 'concat',
184
+ call: 'concat(a, b, ...)',
185
+ detail: 'the values joined into one text (blanks left out)',
186
+ group: 'Text',
187
+ text: true,
188
+ fn: (...args) => args
189
+ .map(asText)
190
+ .filter((s) => s !== null)
191
+ .join(''),
192
+ },
193
+ {
194
+ name: 'contains',
195
+ call: 'contains(text, part)',
196
+ detail: 'whether the text holds part (any case)',
197
+ group: 'Text',
198
+ fn: onText((s, p) => {
199
+ const q = asText(p);
200
+ return q === null ? null : s.toLowerCase().includes(q.toLowerCase());
201
+ }),
202
+ },
203
+ {
204
+ name: 'replace',
205
+ call: 'replace(text, find, with)',
206
+ detail: 'every find swapped for with',
207
+ group: 'Text',
208
+ text: true,
209
+ fn: onText((s, find, to) => {
210
+ const f = asText(find);
211
+ return f ? s.replaceAll(f, asText(to) ?? '') : null;
212
+ }),
213
+ },
214
+ {
215
+ name: 'split_part',
216
+ call: "split_part(text, ',', n)",
217
+ detail: 'the nth piece between separators (the first is 1)',
218
+ group: 'Text',
219
+ text: true,
220
+ fn: onText((s, sep, n) => {
221
+ const by = asText(sep);
222
+ const k = asNum(n);
223
+ return by && k !== null && k >= 1 ? (s.split(by)[k - 1] ?? null) : null;
224
+ }),
225
+ },
226
+ {
227
+ name: 'round',
228
+ call: 'round(x, digits)',
229
+ detail: 'rounded, to digits places (0 when left out)',
230
+ group: 'Number',
231
+ fn: (v, digits) => {
232
+ const x = asNum(v);
233
+ const p = 10 ** (asNum(digits) ?? 0);
234
+ return x === null ? undefined : Math.round(x * p) / p;
235
+ },
236
+ },
237
+ {
238
+ name: 'abs',
239
+ call: 'abs(x)',
240
+ detail: 'without its sign',
241
+ group: 'Number',
242
+ fn: (v) => {
243
+ const x = asNum(v);
244
+ return x === null ? undefined : Math.abs(x);
245
+ },
246
+ },
247
+ {
248
+ name: 'min',
249
+ call: 'min(a, b, ...)',
250
+ detail: 'the smallest (blanks left out)',
251
+ group: 'Number',
252
+ fn: (...args) => {
253
+ const xs = args.map(asNum).filter((x) => x !== null);
254
+ return xs.length ? Math.min(...xs) : undefined;
255
+ },
256
+ },
257
+ {
258
+ name: 'max',
259
+ call: 'max(a, b, ...)',
260
+ detail: 'the largest (blanks left out)',
261
+ group: 'Number',
262
+ fn: (...args) => {
263
+ const xs = args.map(asNum).filter((x) => x !== null);
264
+ return xs.length ? Math.max(...xs) : undefined;
265
+ },
266
+ },
267
+ ];
268
+ /** The functions as one line each, for an assistant's instructions. */
269
+ export const formulaFunctionsText = () => FORMULA_FUNCTIONS.map((f) => `${f.call}: ${f.detail}`).join('; ');
@@ -5,7 +5,7 @@ export interface ViewFormulaScope {
5
5
  groupSum: (key: string) => number;
6
6
  totalSum: (key: string) => number;
7
7
  }
8
- export declare const viewFormulaFunctions: readonly ['group_sum', 'total_sum'];
8
+ export declare const viewFormulaFunctions: readonly ["group_sum", "total_sum", ...string[]];
9
9
  /** Compiles a formula (throws on a syntax error) into a row evaluator; a failed row is null. */
10
10
  export declare function compileViewFormula(expr: string): (scope: ViewFormulaScope) => number | string | null;
11
11
  /**
@@ -9,14 +9,25 @@
9
9
  // `account.cash`) or a linked domain (`tax_lots.unrealized`); `frame.<frame>.<column>` for an
10
10
  // assistant frame; and another formula by its bare key (`value_nonretire`), at any depth, never in a
11
11
  // loop. Rows keep their flat field keys; each surface maps a name to its key (`FormulaNames`).
12
+ // Beyond the two sums it calls the date, text and number functions in `FORMULA_FUNCTIONS`
13
+ // (`year(transactions.date)`); any other function is refused by the check.
12
14
  import jexl from 'jexl';
13
- export const viewFormulaFunctions = ['group_sum', 'total_sum'];
15
+ import { FORMULA_FUNCTIONS } from './formula-functions.js';
16
+ export const viewFormulaFunctions = [
17
+ 'group_sum',
18
+ 'total_sum',
19
+ ...FORMULA_FUNCTIONS.map((f) => f.name),
20
+ ];
21
+ const knownFunctions = new Set(viewFormulaFunctions);
22
+ const textFunctions = new Set(FORMULA_FUNCTIONS.filter((f) => f.text).map((f) => f.name));
14
23
  // Functions get only their arguments, so the row being evaluated is set around each evalSync
15
24
  // (evaluation is synchronous, so one at a time).
16
25
  let current = null;
17
26
  const engine = new jexl.Jexl();
18
27
  engine.addFunction('group_sum', (key) => typeof key === 'string' && current ? current.groupSum(key) : Number.NaN);
19
28
  engine.addFunction('total_sum', (key) => typeof key === 'string' && current ? current.totalSum(key) : Number.NaN);
29
+ for (const f of FORMULA_FUNCTIONS)
30
+ engine.addFunction(f.name, f.fn);
20
31
  /**
21
32
  * Folds each dotted name (`symbol.price`, Jexl's member access) into one identifier whose value is
22
33
  * the whole name, so the scope reads `symbol.price` in one call, as it reads a bare formula key.
@@ -122,6 +133,8 @@ const endsInText = (a) => {
122
133
  return false;
123
134
  if (a.type === 'Literal')
124
135
  return typeof a.value === 'string';
136
+ if (a.type === 'FunctionCall')
137
+ return textFunctions.has(String(a.name));
125
138
  if (a.type === 'ConditionalExpression')
126
139
  return endsInText(a.consequent) && endsInText(a.alternate);
127
140
  if (a.type === 'BinaryExpression' && a.operator === '+')
@@ -141,40 +154,71 @@ export function viewFormulaIsText(expr) {
141
154
  return false;
142
155
  }
143
156
  }
157
+ /** The functions a tree calls that the formula language lacks (a `|transform` is one too). */
158
+ function unknownFunctions(n, out) {
159
+ if (!n || typeof n !== 'object')
160
+ return out;
161
+ if (Array.isArray(n)) {
162
+ for (const x of n)
163
+ unknownFunctions(x, out);
164
+ return out;
165
+ }
166
+ const node = n;
167
+ if (node.type === 'FunctionCall' &&
168
+ (node.pool !== 'functions' || !knownFunctions.has(String(node.name))))
169
+ out.add(String(node.name));
170
+ for (const v of Object.values(node))
171
+ unknownFunctions(v, out);
172
+ return out;
173
+ }
174
+ /**
175
+ * The names a tree reads, found in the tree rather than by running it, so a name on a branch that
176
+ * did not run, or inside a function given nothing, still counts: each name (dotted names folded)
177
+ * and each quoted group_sum or total_sum argument.
178
+ */
179
+ function namesIn(n, out) {
180
+ if (!n || typeof n !== 'object')
181
+ return out;
182
+ if (Array.isArray(n)) {
183
+ for (const x of n)
184
+ namesIn(x, out);
185
+ return out;
186
+ }
187
+ const node = n;
188
+ if (node.type === 'Identifier' && !node.from && !node.relative)
189
+ out.add(String(node.value));
190
+ if (node.type === 'FunctionCall' &&
191
+ (node.name === 'group_sum' || node.name === 'total_sum') &&
192
+ node.args?.[0]?.type === 'Literal' &&
193
+ typeof node.args[0].value === 'string')
194
+ out.add(node.args[0].value);
195
+ for (const v of Object.values(node))
196
+ namesIn(v, out);
197
+ return out;
198
+ }
144
199
  /** Why a formula can't run on fields `keys` (null: any field), or null when it can. */
145
200
  export function checkViewFormula(expr, keys) {
146
- let run;
201
+ let ast;
147
202
  try {
148
- run = compileViewFormula(expr);
203
+ ast = compileNamed(expr)._getAst();
149
204
  }
150
205
  catch (e) {
151
206
  return `syntax: ${e instanceof Error ? e.message : String(e)}`;
152
207
  }
153
- const unknown = new Set();
154
- const known = (k) => {
155
- if (keys && !keys.includes(k))
156
- unknown.add(k);
157
- return 1;
158
- };
159
- run({ value: known, groupSum: known, totalSum: known });
160
- return unknown.size ? `unknown field ${[...unknown].join(', ')}` : null;
208
+ const missing = [...unknownFunctions(ast, new Set())];
209
+ if (missing.length)
210
+ return `unknown function ${missing.join(', ')} (functions: ${viewFormulaFunctions.join(', ')})`;
211
+ const unknown = keys ? [...namesIn(ast, new Set())].filter((k) => !keys.includes(k)) : [];
212
+ return unknown.length ? `unknown field ${unknown.join(', ')}` : null;
161
213
  }
162
214
  /** The names a formula reads (fields and formulas, `group_sum` arguments included); [] when it does not parse. */
163
215
  export function formulaReads(expr) {
164
- let run;
165
216
  try {
166
- run = compileViewFormula(expr);
217
+ return [...namesIn(compileNamed(expr)._getAst(), new Set())];
167
218
  }
168
219
  catch {
169
220
  return [];
170
221
  }
171
- const read = new Set();
172
- const note = (k) => {
173
- read.add(k);
174
- return 1;
175
- };
176
- run({ value: note, groupSum: note, totalSum: note });
177
- return [...read];
178
222
  }
179
223
  /** A frame's part of a name: `Earnings next week` is `frame.earnings_next_week`. */
180
224
  export function frameSpace(name) {
package/dist/views.d.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { type JoinKey } from './domains.js';
3
3
  import { type FormulaNames } from './view-formula.js';
4
4
  export { checkViewFormula, compileViewFormula, type FormulaNames, formulaLoops, formulaReads, frameSpace, renameFormula, viewFormulaFunctions, type ViewFormulaScope, viewFormulaIsText, whyBlank, } from './view-formula.js';
5
+ export { FORMULA_FUNCTIONS, type FormulaFunction, formulaFunctionsText, } from './formula-functions.js';
5
6
  /** How a view shows its rows; the sidebar icon follows it. */
6
7
  export declare const viewShapes: readonly ['table', 'group', 'pivot'];
7
8
  export type ViewShape = (typeof viewShapes)[number];
package/dist/views.js CHANGED
@@ -7,6 +7,7 @@ import { DOMAIN_KEYS, JOINS, joinFields, joinKeys } from './domains.js';
7
7
  import { MARKET_KEY_RE, marketWindows } from './market.js';
8
8
  import { checkViewFormula, formulaLoops, frameSpace, renameFormula, } from './view-formula.js';
9
9
  export { checkViewFormula, compileViewFormula, formulaLoops, formulaReads, frameSpace, renameFormula, viewFormulaFunctions, viewFormulaIsText, whyBlank, } from './view-formula.js';
10
+ export { FORMULA_FUNCTIONS, formulaFunctionsText, } from './formula-functions.js';
10
11
  const viewKey = z.string().regex(/^[a-z][a-z0-9_]{0,31}$/, 'key: lowercase letters, digits and _');
11
12
  const MAX_VALUES = 40;
12
13
  /** How a view shows its rows; the sidebar icon follows it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui-types",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Lumen's view model: views, formulas, domains and joins, frames.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/view-formula.d.ts",
20
20
  "default": "./dist/view-formula.js"
21
21
  },
22
+ "./formula-functions": {
23
+ "types": "./dist/formula-functions.d.ts",
24
+ "default": "./dist/formula-functions.js"
25
+ },
22
26
  "./domains": {
23
27
  "types": "./dist/domains.d.ts",
24
28
  "default": "./dist/domains.js"
@@ -72,6 +76,8 @@
72
76
  "registry": "https://registry.npmjs.org/"
73
77
  },
74
78
  "dependencies": {
79
+ "@date-fns/utc": "2.1.0",
80
+ "date-fns": "4.1.0",
75
81
  "jexl": "2.3.0",
76
82
  "zod": "^4.3.6"
77
83
  }