@talelk/newui-data 1.0.0 → 1.1.0
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 -2
- package/package.json +1 -1
- package/skills/newui-data/SKILL.md +59 -5
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/exports.test.ts +33 -3
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.tsx +122 -2
- package/src/form/index.ts +2 -0
- package/src/index.ts +6 -0
- package/src/query/collection.test.tsx +130 -3
- package/src/query/collection.ts +15 -0
- package/src/query/index.ts +1 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/index.ts +7 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
package/README.md
CHANGED
|
@@ -21,6 +21,15 @@ The first four surfaces are the ones the examples had already copied:
|
|
|
21
21
|
| `undo` | `createUndo` — a snapshot, a restore and a clear, without the page's notice copy |
|
|
22
22
|
| `download` | `downloadBlob`, `downloadText`, `downloadCsv`, `csvText` — the Blob/anchor/revoke dance and RFC-shaped quoting |
|
|
23
23
|
|
|
24
|
+
The analytics primitives, decided in issues #22, #23, #24 and #32, round out the
|
|
25
|
+
headless layer:
|
|
26
|
+
|
|
27
|
+
| surface | what it owns |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `url-state` | `createUrlState` — link-reproducible signals behind a router adapter, replace-not-push, debounced search keys |
|
|
30
|
+
| `config-store` | `createConfigStore` — versioned envelopes for saved views and dashboard config over a storage seam |
|
|
31
|
+
| `aggregate` | `aggregate`, `timeBucket`, `rollup` — pure group-by and time-series transforms over rows the app already fetched |
|
|
32
|
+
|
|
24
33
|
## Query
|
|
25
34
|
|
|
26
35
|
```tsx
|
|
@@ -51,5 +60,7 @@ has shipped.
|
|
|
51
60
|
The Frappe DocType client: `request`/`frappeRequest` transport with
|
|
52
61
|
`setConfig`, document and list hooks (`useDoc`, `useDoctype`, `useList`,
|
|
53
62
|
`useNewDoc`, `useCall`), an IndexedDB cache, and a shared WebSocket realtime
|
|
54
|
-
multiplexer
|
|
55
|
-
|
|
63
|
+
multiplexer (`createRealtime`, plus `connectRealtime` to invalidate query keys
|
|
64
|
+
from socket events). It lives behind its own entry so importing `usd` never
|
|
65
|
+
pulls a fetch stack, and it is app-specific — generic query needs belong on
|
|
66
|
+
`/query`.
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: newui-data
|
|
3
3
|
description: Use the headless newui data layer for SolidJS collections,
|
|
4
|
-
repository queries, optimistic mutations, TanStack Query caching
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
repository queries, optimistic mutations, TanStack Query caching, form submit
|
|
5
|
+
lifecycles, URL-synced state, saved-view/dashboard-config storage and
|
|
6
|
+
aggregation/rollup transforms. Use when a page needs to load a list,
|
|
7
|
+
create/update/delete records, share a cache, sync filters or views to the URL,
|
|
8
|
+
persist saved views, build chart series, or wire TanStack Form through the
|
|
9
|
+
kit's Form and field-error contract.
|
|
8
10
|
metadata:
|
|
9
11
|
type: core
|
|
10
12
|
domain: data
|
|
11
13
|
library: '@talelk/newui-data'
|
|
12
|
-
library_version: '1.
|
|
14
|
+
library_version: '1.1.0'
|
|
13
15
|
sources:
|
|
14
16
|
- talelk/newui:spec/data.md
|
|
15
17
|
- talelk/newui:skills/README.md
|
|
@@ -18,6 +20,10 @@ sources:
|
|
|
18
20
|
- talelk/newui:packages/data/src/query/collection.ts
|
|
19
21
|
- talelk/newui:packages/data/src/query/repository.ts
|
|
20
22
|
- talelk/newui:packages/data/src/form/form.tsx
|
|
23
|
+
- talelk/newui:packages/data/src/url-state.ts
|
|
24
|
+
- talelk/newui:packages/data/src/config-store.ts
|
|
25
|
+
- talelk/newui:packages/data/src/aggregate.ts
|
|
26
|
+
- talelk/newui:packages/data/src/resource/connect-realtime.ts
|
|
21
27
|
---
|
|
22
28
|
|
|
23
29
|
# newui-data
|
|
@@ -114,3 +120,51 @@ const { form, submitting } = createAppForm({
|
|
|
114
120
|
de-duplicates the same validator message.
|
|
115
121
|
- Pass the same message to `onChange` and `onSubmit` when the field should
|
|
116
122
|
validate on both; `fieldError` will not print it twice.
|
|
123
|
+
|
|
124
|
+
### Config forms: the field map, pruning and autosave
|
|
125
|
+
|
|
126
|
+
- `createAppForm({ schema, ui, pruneHidden })` takes a Standard Schema and a
|
|
127
|
+
**field map** (`ui`) keyed by *static* paths (`smtp.host`, never `items[1]`
|
|
128
|
+
— that throws; a repeater's row fields go under its `fields`). Pass the same
|
|
129
|
+
object to the `ConfigForm` organism's `fields`.
|
|
130
|
+
- `when(scope, values)` hides a field: `scope` is the form values for a
|
|
131
|
+
top-level entry and **the row** for a repeater's row field. Hidden fields
|
|
132
|
+
are pruned from the submitted document (`pruneHidden` defaults to `true`),
|
|
133
|
+
and schema issues under them never block submit.
|
|
134
|
+
- `app.document(values)` is that submitted document. Give
|
|
135
|
+
`createAutoSave(app, { save, id })` the `createAppForm` result, not
|
|
136
|
+
`app.form`, so autosave writes the same pruned document submit sends.
|
|
137
|
+
- With `id`, autosave keeps a local, unpruned **draft**; a successful autosave
|
|
138
|
+
or submit drops it. `restoreDraft()` is untrusted input (it may predate the
|
|
139
|
+
schema) — reset the form with it and let validation report what no longer
|
|
140
|
+
fits.
|
|
141
|
+
|
|
142
|
+
## URL state, saved views and transforms
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { createConfigStore, createUrlState, rollup } from '@talelk/newui-data'
|
|
146
|
+
|
|
147
|
+
// Link-reproducible state; the app owns the router through `adapter`.
|
|
148
|
+
const filters = createUrlState(
|
|
149
|
+
{ 'board.view': 'kanban', q: '' },
|
|
150
|
+
{ adapter, debounce: ['q'] },
|
|
151
|
+
)
|
|
152
|
+
filters['board.view']()
|
|
153
|
+
filters.set('q', 'invoice') // undefined = absent, never ""
|
|
154
|
+
|
|
155
|
+
// Saved views and dashboard configs: versioned envelopes, storage-agnostic.
|
|
156
|
+
const views = createConfigStore({ key: 'acme.views' })
|
|
157
|
+
views.save('week-only', { filters: [] }, { type: 'saved-view' })
|
|
158
|
+
|
|
159
|
+
// Chart series from a time column — pure, no query, no components.
|
|
160
|
+
const series = rollup(rows, { field: 'createdAt', unit: 'day', metric: { field: 'amount', metric: 'sum' } })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `createUrlState` reads the URL once on mount and **replaces** (never pushes);
|
|
164
|
+
without an adapter it is a plain signal, so tests and SSR stay clean.
|
|
165
|
+
- `createConfigStore` documents are capped at 64KB with a dev warning; a
|
|
166
|
+
server-backed adapter drops in by passing `storage`, no consumer changes.
|
|
167
|
+
- `aggregate`/`rollup` run over rows the app already fetched — past that size
|
|
168
|
+
the answer is server-side, not a client pivot.
|
|
169
|
+
- A collection can poll with `refetchInterval`; `connectRealtime` (from
|
|
170
|
+
`@talelk/newui-data/resource`) invalidates query keys from socket events.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { type AggregateRow, aggregate, rollup, timeBucket } from './aggregate'
|
|
3
|
+
|
|
4
|
+
/** Local-time dates so the buckets do not depend on the runner's zone. */
|
|
5
|
+
const date = (year: number, month: number, day: number, hour = 0, min = 0) =>
|
|
6
|
+
new Date(year, month - 1, day, hour, min)
|
|
7
|
+
|
|
8
|
+
describe('aggregate', () => {
|
|
9
|
+
const rows: AggregateRow[] = [
|
|
10
|
+
{ region: 'north', amount: 10, tag: 'a' },
|
|
11
|
+
{ region: 'north', amount: 20, tag: 'a' },
|
|
12
|
+
{ region: 'south', amount: 5, tag: 'b' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
it('groups by one column and computes sum and count', () => {
|
|
16
|
+
const out = aggregate(rows, {
|
|
17
|
+
by: 'region',
|
|
18
|
+
metrics: {
|
|
19
|
+
total: { field: 'amount', metric: 'sum' },
|
|
20
|
+
n: { field: 'amount', metric: 'count' },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
expect(out).toEqual([
|
|
24
|
+
{ region: 'north', total: 30, n: 2 },
|
|
25
|
+
{ region: 'south', total: 5, n: 1 },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('groups by several columns', () => {
|
|
30
|
+
const out = aggregate(
|
|
31
|
+
[
|
|
32
|
+
{ region: 'north', tag: 'a', amount: 1 },
|
|
33
|
+
{ region: 'north', tag: 'b', amount: 2 },
|
|
34
|
+
{ region: 'north', tag: 'a', amount: 3 },
|
|
35
|
+
],
|
|
36
|
+
{
|
|
37
|
+
by: ['region', 'tag'],
|
|
38
|
+
metrics: { total: { field: 'amount', metric: 'sum' } },
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
expect(out).toEqual([
|
|
42
|
+
{ region: 'north', tag: 'a', total: 4 },
|
|
43
|
+
{ region: 'north', tag: 'b', total: 2 },
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('computes min, max and extent', () => {
|
|
48
|
+
const out = aggregate(rows, {
|
|
49
|
+
by: 'region',
|
|
50
|
+
metrics: {
|
|
51
|
+
low: { field: 'amount', metric: 'min' },
|
|
52
|
+
high: { field: 'amount', metric: 'max' },
|
|
53
|
+
range: { field: 'amount', metric: 'extent' },
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
expect(out[0]).toEqual({
|
|
57
|
+
region: 'north',
|
|
58
|
+
low: 10,
|
|
59
|
+
high: 20,
|
|
60
|
+
range: [10, 20],
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('computes mean and median over numeric values only', () => {
|
|
65
|
+
const out = aggregate(
|
|
66
|
+
[
|
|
67
|
+
{ g: 'x', amount: 10 },
|
|
68
|
+
{ g: 'x', amount: 'nope' },
|
|
69
|
+
{ g: 'x', amount: 30 },
|
|
70
|
+
],
|
|
71
|
+
{
|
|
72
|
+
by: 'g',
|
|
73
|
+
metrics: {
|
|
74
|
+
avg: { field: 'amount', metric: 'mean' },
|
|
75
|
+
mid: { field: 'amount', metric: 'median' },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
expect(out[0]?.['avg']).toBe(20)
|
|
80
|
+
expect(out[0]?.['mid']).toBe(20)
|
|
81
|
+
|
|
82
|
+
const even = aggregate(
|
|
83
|
+
[
|
|
84
|
+
{ g: 'x', amount: 1 },
|
|
85
|
+
{ g: 'x', amount: 3 },
|
|
86
|
+
],
|
|
87
|
+
{ by: 'g', metrics: { mid: { field: 'amount', metric: 'median' } } },
|
|
88
|
+
)
|
|
89
|
+
expect(even[0]?.['mid']).toBe(2)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('computes unique and uniqueCount in first-seen order', () => {
|
|
93
|
+
const out = aggregate(rows, {
|
|
94
|
+
by: 'region',
|
|
95
|
+
metrics: {
|
|
96
|
+
tags: { field: 'tag', metric: 'unique' },
|
|
97
|
+
tagCount: { field: 'tag', metric: 'uniqueCount' },
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
expect(out[0]?.['tags']).toEqual(['a'])
|
|
101
|
+
expect(out[0]?.['tagCount']).toBe(1)
|
|
102
|
+
const all = aggregate(rows, {
|
|
103
|
+
by: [],
|
|
104
|
+
metrics: { tagCount: { field: 'tag', metric: 'uniqueCount' } },
|
|
105
|
+
})
|
|
106
|
+
expect(all).toHaveLength(1)
|
|
107
|
+
expect(all[0]?.['tagCount']).toBe(2)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('takes first and last values in row order', () => {
|
|
111
|
+
const out = aggregate(rows, {
|
|
112
|
+
by: 'region',
|
|
113
|
+
metrics: {
|
|
114
|
+
opener: { field: 'tag', metric: 'first' },
|
|
115
|
+
closer: { field: 'tag', metric: 'last' },
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
expect(out[0]?.['opener']).toBe('a')
|
|
119
|
+
expect(out[0]?.['closer']).toBe('a')
|
|
120
|
+
expect(out[1]?.['opener']).toBe('b')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('groups dates by their instant, not their object identity', () => {
|
|
124
|
+
const out = aggregate(
|
|
125
|
+
[
|
|
126
|
+
{ day: date(2026, 1, 5), amount: 1 },
|
|
127
|
+
{ day: new Date(date(2026, 1, 5).getTime()), amount: 2 },
|
|
128
|
+
],
|
|
129
|
+
{ by: 'day', metrics: { total: { field: 'amount', metric: 'sum' } } },
|
|
130
|
+
)
|
|
131
|
+
expect(out).toHaveLength(1)
|
|
132
|
+
expect(out[0]?.['total']).toBe(3)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('handles empty input and missing fields', () => {
|
|
136
|
+
expect(
|
|
137
|
+
aggregate([], { by: 'a', metrics: { s: { field: 'b', metric: 'sum' } } }),
|
|
138
|
+
).toEqual([])
|
|
139
|
+
const out = aggregate([{ g: 'x' }], {
|
|
140
|
+
by: 'g',
|
|
141
|
+
metrics: {
|
|
142
|
+
sum: { field: 'missing', metric: 'sum' },
|
|
143
|
+
min: { field: 'missing', metric: 'min' },
|
|
144
|
+
count: { field: 'missing', metric: 'count' },
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
expect(out[0]?.['sum']).toBe(0)
|
|
148
|
+
expect(out[0]?.['min']).toBeUndefined()
|
|
149
|
+
expect(out[0]?.['count']).toBe(1)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('timeBucket', () => {
|
|
154
|
+
const noon = date(2026, 3, 12, 14, 37) // Thursday afternoon
|
|
155
|
+
|
|
156
|
+
it('floors to the hour and the day', () => {
|
|
157
|
+
expect(timeBucket(noon, 'hour')).toEqual(date(2026, 3, 12, 14))
|
|
158
|
+
expect(timeBucket(noon, 'day')).toEqual(date(2026, 3, 12))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('starts weeks on Monday', () => {
|
|
162
|
+
expect(timeBucket(noon, 'week')).toEqual(date(2026, 3, 9))
|
|
163
|
+
expect(timeBucket(date(2026, 3, 8, 3), 'week')).toEqual(date(2026, 3, 2))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('starts months, quarters and years on their first day', () => {
|
|
167
|
+
expect(timeBucket(noon, 'month')).toEqual(date(2026, 3, 1))
|
|
168
|
+
expect(timeBucket(noon, 'quarter')).toEqual(date(2026, 1, 1))
|
|
169
|
+
expect(timeBucket(date(2026, 5, 20), 'quarter')).toEqual(date(2026, 4, 1))
|
|
170
|
+
expect(timeBucket(noon, 'year')).toEqual(date(2026, 1, 1))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('accepts ISO strings and epochs, and stays Invalid when unparseable', () => {
|
|
174
|
+
const ms = date(2026, 3, 12, 14).getTime()
|
|
175
|
+
expect(timeBucket(new Date(ms).toISOString(), 'day')).toEqual(
|
|
176
|
+
date(2026, 3, 12),
|
|
177
|
+
)
|
|
178
|
+
expect(timeBucket(ms, 'day')).toEqual(date(2026, 3, 12))
|
|
179
|
+
expect(Number.isNaN(timeBucket('not a date', 'day').getTime())).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('rollup', () => {
|
|
184
|
+
const rows = [
|
|
185
|
+
{ createdAt: date(2026, 1, 3, 9), amount: 5 },
|
|
186
|
+
{ createdAt: date(2026, 1, 3, 17), amount: 10 },
|
|
187
|
+
{ createdAt: date(2026, 1, 4, 9), amount: 7 },
|
|
188
|
+
{ createdAt: 'yesterday', amount: 1 },
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
it('counts rows per bucket, ascending, skipping unparseable dates', () => {
|
|
192
|
+
const series = rollup(rows, { field: 'createdAt', unit: 'day' })
|
|
193
|
+
expect(series).toEqual([
|
|
194
|
+
{ bucket: date(2026, 1, 3), value: 2 },
|
|
195
|
+
{ bucket: date(2026, 1, 4), value: 1 },
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('applies a value metric per bucket', () => {
|
|
200
|
+
const series = rollup(rows, {
|
|
201
|
+
field: 'createdAt',
|
|
202
|
+
unit: 'day',
|
|
203
|
+
metric: { field: 'amount', metric: 'sum' },
|
|
204
|
+
})
|
|
205
|
+
expect(series.map((point) => point.value)).toEqual([15, 7])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('buckets by month with a bare metric name', () => {
|
|
209
|
+
const series = rollup(rows, {
|
|
210
|
+
field: 'createdAt',
|
|
211
|
+
unit: 'month',
|
|
212
|
+
metric: 'count',
|
|
213
|
+
})
|
|
214
|
+
expect(series).toEqual([{ bucket: date(2026, 1, 1), value: 3 }])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('keeps input order for first and last within a bucket', () => {
|
|
218
|
+
const series = rollup(
|
|
219
|
+
[
|
|
220
|
+
{ createdAt: date(2026, 1, 3, 17), label: 'late' },
|
|
221
|
+
{ createdAt: date(2026, 1, 3, 9), label: 'early' },
|
|
222
|
+
],
|
|
223
|
+
{
|
|
224
|
+
field: 'createdAt',
|
|
225
|
+
unit: 'day',
|
|
226
|
+
metric: { field: 'label', metric: 'first' },
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
expect(series[0]?.value).toBe('late')
|
|
230
|
+
})
|
|
231
|
+
})
|
package/src/aggregate.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure transforms over rows the app already fetched.
|
|
3
|
+
*
|
|
4
|
+
* `aggregate` groups rows with the same eleven metric names table aggregation
|
|
5
|
+
* uses, so one vocabulary spans tables and transforms; `timeBucket` and
|
|
6
|
+
* `rollup` produce chart series from a time column. These are plain functions
|
|
7
|
+
* — no signals, no query, no components — and past their size the answer is
|
|
8
|
+
* server-side, per server mode's own doctrine.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The kit's eleven aggregation metrics, shared with table aggregation. */
|
|
12
|
+
export type Metric =
|
|
13
|
+
| 'sum'
|
|
14
|
+
| 'count'
|
|
15
|
+
| 'min'
|
|
16
|
+
| 'max'
|
|
17
|
+
| 'extent'
|
|
18
|
+
| 'mean'
|
|
19
|
+
| 'median'
|
|
20
|
+
| 'unique'
|
|
21
|
+
| 'uniqueCount'
|
|
22
|
+
| 'first'
|
|
23
|
+
| 'last'
|
|
24
|
+
|
|
25
|
+
/** The bucket units {@link timeBucket} and {@link rollup} understand. */
|
|
26
|
+
export type TimeUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
27
|
+
|
|
28
|
+
/** A metric applied to one field. `count` ignores the field's values. */
|
|
29
|
+
export interface MetricSpec {
|
|
30
|
+
field: string
|
|
31
|
+
metric: Metric
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A row is whatever the app's query returned. */
|
|
35
|
+
export type AggregateRow = Record<string, unknown>
|
|
36
|
+
|
|
37
|
+
/** Options for {@link aggregate}. */
|
|
38
|
+
export interface AggregateOptions {
|
|
39
|
+
/** Group-by columns. The output row carries each group's value. */
|
|
40
|
+
by: string | readonly string[]
|
|
41
|
+
/** Output column → the field to aggregate and the metric to apply. */
|
|
42
|
+
metrics: Record<string, MetricSpec>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A bucketed series point, shaped for a chart's `{ x, y }` series. */
|
|
46
|
+
export interface RollupPoint {
|
|
47
|
+
/** The start of the bucket, in local time. */
|
|
48
|
+
bucket: Date
|
|
49
|
+
value: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options for {@link rollup}. */
|
|
53
|
+
export interface RollupOptions {
|
|
54
|
+
/** The row's date column. */
|
|
55
|
+
field: string
|
|
56
|
+
unit: TimeUnit
|
|
57
|
+
/**
|
|
58
|
+
* What to compute per bucket. A bare metric name counts rows (its field is
|
|
59
|
+
* the date column, which is what a count of a time series means); pass a
|
|
60
|
+
* {@link MetricSpec} to aggregate a value column per bucket.
|
|
61
|
+
*/
|
|
62
|
+
metric?: Metric | MetricSpec
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A date, an ISO string, or an epoch — anything a chart hands back. */
|
|
66
|
+
export type DateLike = Date | string | number
|
|
67
|
+
|
|
68
|
+
function toDate(value: unknown): Date {
|
|
69
|
+
if (value instanceof Date) return value
|
|
70
|
+
if (typeof value === 'number') return new Date(value)
|
|
71
|
+
if (typeof value === 'string') return new Date(value)
|
|
72
|
+
return new Date(Number.NaN)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Coerce to a number for the numeric metrics; `undefined` when not numeric. */
|
|
76
|
+
function numeric(value: unknown): number | undefined {
|
|
77
|
+
if (typeof value === 'number') {
|
|
78
|
+
return Number.isFinite(value) ? value : undefined
|
|
79
|
+
}
|
|
80
|
+
if (value instanceof Date) return value.getTime()
|
|
81
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
82
|
+
const parsed = Number(value)
|
|
83
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Identity for `unique`/`uniqueCount`: JSON so objects compare by shape. */
|
|
89
|
+
function identity(value: unknown): string {
|
|
90
|
+
if (value === undefined) return 'undefined'
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(value) ?? String(value)
|
|
93
|
+
} catch {
|
|
94
|
+
return String(value)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function uniqueValues(values: readonly unknown[]): unknown[] {
|
|
99
|
+
const seen = new Set<string>()
|
|
100
|
+
const out: unknown[] = []
|
|
101
|
+
for (const value of values) {
|
|
102
|
+
const key = identity(value)
|
|
103
|
+
if (!seen.has(key)) {
|
|
104
|
+
seen.add(key)
|
|
105
|
+
out.push(value)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function median(values: readonly number[]): number | undefined {
|
|
112
|
+
if (values.length === 0) return undefined
|
|
113
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
114
|
+
const middle = Math.floor(sorted.length / 2)
|
|
115
|
+
if (sorted.length % 2 === 1) return sorted[middle]
|
|
116
|
+
const low = sorted[middle - 1]
|
|
117
|
+
const high = sorted[middle]
|
|
118
|
+
if (low === undefined || high === undefined) return undefined
|
|
119
|
+
return (low + high) / 2
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Apply one metric to a group's rows. Internal to the transform module. */
|
|
123
|
+
function applyMetric(rows: readonly AggregateRow[], spec: MetricSpec): unknown {
|
|
124
|
+
const values = rows.map((row) => row[spec.field])
|
|
125
|
+
switch (spec.metric) {
|
|
126
|
+
case 'count':
|
|
127
|
+
return rows.length
|
|
128
|
+
case 'sum': {
|
|
129
|
+
let total = 0
|
|
130
|
+
for (const value of values) total += numeric(value) ?? 0
|
|
131
|
+
return total
|
|
132
|
+
}
|
|
133
|
+
case 'min': {
|
|
134
|
+
let best: number | undefined
|
|
135
|
+
for (const value of values) {
|
|
136
|
+
const n = numeric(value)
|
|
137
|
+
if (n !== undefined && (best === undefined || n < best)) best = n
|
|
138
|
+
}
|
|
139
|
+
return best
|
|
140
|
+
}
|
|
141
|
+
case 'max': {
|
|
142
|
+
let best: number | undefined
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
const n = numeric(value)
|
|
145
|
+
if (n !== undefined && (best === undefined || n > best)) best = n
|
|
146
|
+
}
|
|
147
|
+
return best
|
|
148
|
+
}
|
|
149
|
+
case 'extent': {
|
|
150
|
+
let low: number | undefined
|
|
151
|
+
let high: number | undefined
|
|
152
|
+
for (const value of values) {
|
|
153
|
+
const n = numeric(value)
|
|
154
|
+
if (n === undefined) continue
|
|
155
|
+
if (low === undefined || n < low) low = n
|
|
156
|
+
if (high === undefined || n > high) high = n
|
|
157
|
+
}
|
|
158
|
+
if (low === undefined || high === undefined) return undefined
|
|
159
|
+
return [low, high]
|
|
160
|
+
}
|
|
161
|
+
case 'mean': {
|
|
162
|
+
const numbers = values
|
|
163
|
+
.map(numeric)
|
|
164
|
+
.filter((n): n is number => n !== undefined)
|
|
165
|
+
if (numbers.length === 0) return undefined
|
|
166
|
+
return (
|
|
167
|
+
numbers.reduce((total, n) => total + n, 0) / (numbers.length as number)
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
case 'median': {
|
|
171
|
+
const numbers = values
|
|
172
|
+
.map(numeric)
|
|
173
|
+
.filter((n): n is number => n !== undefined)
|
|
174
|
+
return median(numbers)
|
|
175
|
+
}
|
|
176
|
+
case 'unique':
|
|
177
|
+
return uniqueValues(values)
|
|
178
|
+
case 'uniqueCount':
|
|
179
|
+
return uniqueValues(values).length
|
|
180
|
+
case 'first':
|
|
181
|
+
return values[0]
|
|
182
|
+
case 'last':
|
|
183
|
+
return values[values.length - 1]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A stable group key for a row's `by` values; dates group by their instant. */
|
|
188
|
+
function groupKey(row: AggregateRow, by: readonly string[]): string {
|
|
189
|
+
return JSON.stringify(
|
|
190
|
+
by.map((key) => {
|
|
191
|
+
const value = row[key]
|
|
192
|
+
return value instanceof Date ? value.toISOString() : value
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Group rows by `by` and compute each named metric per group.
|
|
199
|
+
*
|
|
200
|
+
* The output rows carry the group columns plus one column per metrics key,
|
|
201
|
+
* in the order groups first appear. `min`/`max`/`extent`/`mean`/`median`
|
|
202
|
+
* ignore non-numeric values (and empty groups yield `undefined`); `count`
|
|
203
|
+
* counts rows, and `first`/`last` keep the row's own value.
|
|
204
|
+
*/
|
|
205
|
+
export function aggregate(
|
|
206
|
+
rows: readonly AggregateRow[],
|
|
207
|
+
options: AggregateOptions,
|
|
208
|
+
): AggregateRow[] {
|
|
209
|
+
const by = typeof options.by === 'string' ? [options.by] : options.by
|
|
210
|
+
const groups = new Map<string, AggregateRow[]>()
|
|
211
|
+
const groupValues = new Map<string, unknown[]>()
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
const key = groupKey(row, by)
|
|
214
|
+
const bucket = groups.get(key)
|
|
215
|
+
if (bucket) {
|
|
216
|
+
bucket.push(row)
|
|
217
|
+
} else {
|
|
218
|
+
groups.set(key, [row])
|
|
219
|
+
groupValues.set(
|
|
220
|
+
key,
|
|
221
|
+
by.map((column) => {
|
|
222
|
+
const value = row[column]
|
|
223
|
+
return value instanceof Date ? new Date(value.getTime()) : value
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const out: AggregateRow[] = []
|
|
230
|
+
for (const [key, bucket] of groups) {
|
|
231
|
+
const result: AggregateRow = {}
|
|
232
|
+
const values = groupValues.get(key) ?? []
|
|
233
|
+
by.forEach((column, index) => {
|
|
234
|
+
result[column] = values[index]
|
|
235
|
+
})
|
|
236
|
+
for (const [id, spec] of Object.entries(options.metrics)) {
|
|
237
|
+
result[id] = applyMetric(bucket, spec)
|
|
238
|
+
}
|
|
239
|
+
out.push(result)
|
|
240
|
+
}
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Floor a date to the start of its bucket, in local time.
|
|
246
|
+
*
|
|
247
|
+
* Weeks start on Monday; months, quarters and years start on their first day
|
|
248
|
+
* at midnight. An unparseable date yields an Invalid Date, so a chart can
|
|
249
|
+
* still map it — `rollup` skips those rows instead.
|
|
250
|
+
*/
|
|
251
|
+
export function timeBucket(date: DateLike, unit: TimeUnit): Date {
|
|
252
|
+
const value = toDate(date)
|
|
253
|
+
if (Number.isNaN(value.getTime())) return new Date(Number.NaN)
|
|
254
|
+
const bucket = new Date(value.getTime())
|
|
255
|
+
switch (unit) {
|
|
256
|
+
case 'hour':
|
|
257
|
+
bucket.setMinutes(0, 0, 0)
|
|
258
|
+
return bucket
|
|
259
|
+
case 'day':
|
|
260
|
+
bucket.setHours(0, 0, 0, 0)
|
|
261
|
+
return bucket
|
|
262
|
+
case 'week': {
|
|
263
|
+
bucket.setHours(0, 0, 0, 0)
|
|
264
|
+
// getDay(): Sunday is 0; shift back to the Monday that starts the week.
|
|
265
|
+
const shift = (bucket.getDay() + 6) % 7
|
|
266
|
+
bucket.setDate(bucket.getDate() - shift)
|
|
267
|
+
return bucket
|
|
268
|
+
}
|
|
269
|
+
case 'month':
|
|
270
|
+
bucket.setHours(0, 0, 0, 0)
|
|
271
|
+
bucket.setDate(1)
|
|
272
|
+
return bucket
|
|
273
|
+
case 'quarter':
|
|
274
|
+
bucket.setHours(0, 0, 0, 0)
|
|
275
|
+
bucket.setDate(1)
|
|
276
|
+
bucket.setMonth(Math.floor(bucket.getMonth() / 3) * 3)
|
|
277
|
+
return bucket
|
|
278
|
+
case 'year':
|
|
279
|
+
bucket.setHours(0, 0, 0, 0)
|
|
280
|
+
bucket.setDate(1)
|
|
281
|
+
bucket.setMonth(0)
|
|
282
|
+
return bucket
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Turn a time column into a chart series: bucket the rows by `unit`, apply
|
|
288
|
+
* `metric` per bucket, and return the buckets in ascending order.
|
|
289
|
+
*
|
|
290
|
+
* Rows whose date column does not parse are skipped — a chart wants the
|
|
291
|
+
* series it can place, not a hole. Rows are read in the given order, so
|
|
292
|
+
* `first`/`last` follow the input's order, not the timeline's.
|
|
293
|
+
*/
|
|
294
|
+
export function rollup(
|
|
295
|
+
rows: readonly AggregateRow[],
|
|
296
|
+
options: RollupOptions,
|
|
297
|
+
): RollupPoint[] {
|
|
298
|
+
const spec: MetricSpec =
|
|
299
|
+
typeof options.metric === 'string'
|
|
300
|
+
? { field: options.field, metric: options.metric }
|
|
301
|
+
: (options.metric ?? { field: options.field, metric: 'count' })
|
|
302
|
+
const groups = new Map<number, AggregateRow[]>()
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
const bucket = timeBucket(row[options.field] as DateLike, options.unit)
|
|
305
|
+
if (Number.isNaN(bucket.getTime())) continue
|
|
306
|
+
const key = bucket.getTime()
|
|
307
|
+
const bucketRows = groups.get(key)
|
|
308
|
+
if (bucketRows) {
|
|
309
|
+
bucketRows.push(row)
|
|
310
|
+
} else {
|
|
311
|
+
groups.set(key, [row])
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return [...groups.entries()]
|
|
315
|
+
.sort((a, b) => a[0] - b[0])
|
|
316
|
+
.map(([time, bucketRows]) => ({
|
|
317
|
+
bucket: new Date(time),
|
|
318
|
+
value: applyMetric(bucketRows, spec),
|
|
319
|
+
}))
|
|
320
|
+
}
|