@talelk/newui-data 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- 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/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- 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.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +77 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
CONFIG_DOCUMENT_LIMIT,
|
|
4
|
+
type ConfigStorage,
|
|
5
|
+
createConfigStore,
|
|
6
|
+
} from './config-store'
|
|
7
|
+
|
|
8
|
+
/** A Map-backed storage that records what was written. */
|
|
9
|
+
function memoryStorage(): ConfigStorage & { map: Map<string, string> } {
|
|
10
|
+
const map = new Map<string, string>()
|
|
11
|
+
return {
|
|
12
|
+
map,
|
|
13
|
+
getItem: (key) => map.get(key) ?? null,
|
|
14
|
+
setItem: (key, value) => void map.set(key, value),
|
|
15
|
+
removeItem: (key) => void map.delete(key),
|
|
16
|
+
keys: () => [...map.keys()],
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createConfigStore', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.useFakeTimers()
|
|
23
|
+
vi.setSystemTime(new Date('2026-03-01T10:00:00Z'))
|
|
24
|
+
})
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers()
|
|
27
|
+
vi.restoreAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('wraps saves in a versioned envelope and loads them back', () => {
|
|
31
|
+
const storage = memoryStorage()
|
|
32
|
+
const store = createConfigStore({ key: 'acme.views', storage })
|
|
33
|
+
|
|
34
|
+
expect(store.save('revenue', { columns: ['date', 'usd'] })).toBe(true)
|
|
35
|
+
const loaded = store.load<{ columns: string[] }>('revenue')
|
|
36
|
+
expect(loaded?.v).toBe(1)
|
|
37
|
+
expect(loaded?.name).toBe('revenue')
|
|
38
|
+
expect(loaded?.updatedAt).toBe('2026-03-01T10:00:00.000Z')
|
|
39
|
+
expect(loaded?.doc).toEqual({ columns: ['date', 'usd'] })
|
|
40
|
+
|
|
41
|
+
// The store namespaces its keys, so a sibling store cannot collide.
|
|
42
|
+
expect([...storage.map.keys()]).toEqual(['acme.views:revenue'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('carries the type discriminator through save and list', () => {
|
|
46
|
+
const store = createConfigStore({
|
|
47
|
+
key: 'acme',
|
|
48
|
+
storage: memoryStorage(),
|
|
49
|
+
})
|
|
50
|
+
store.save('week-only', { filters: [] }, { type: 'saved-view' })
|
|
51
|
+
store.save('layout', { widgets: [] }, { type: 'dashboard-config' })
|
|
52
|
+
|
|
53
|
+
const listed = store.list()
|
|
54
|
+
expect(listed).toHaveLength(2)
|
|
55
|
+
expect(listed.map((row) => row.type).sort()).toEqual([
|
|
56
|
+
'dashboard-config',
|
|
57
|
+
'saved-view',
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('lists documents newest first and skips unreadable entries', () => {
|
|
62
|
+
const storage = memoryStorage()
|
|
63
|
+
const store = createConfigStore({ key: 'acme', storage })
|
|
64
|
+
store.save('old', { n: 1 })
|
|
65
|
+
vi.setSystemTime(new Date('2026-03-02T10:00:00Z'))
|
|
66
|
+
store.save('new', { n: 2 })
|
|
67
|
+
storage.map.set('acme:garbage', '{not json')
|
|
68
|
+
|
|
69
|
+
expect(store.list().map((row) => row.name)).toEqual(['new', 'old'])
|
|
70
|
+
expect(store.load('garbage')).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('removes and renames, reporting whether the document existed', () => {
|
|
74
|
+
const storage = memoryStorage()
|
|
75
|
+
const store = createConfigStore({ key: 'acme', storage })
|
|
76
|
+
store.save('draft', { n: 1 })
|
|
77
|
+
|
|
78
|
+
expect(store.remove('draft')).toBe(true)
|
|
79
|
+
expect(store.remove('draft')).toBe(false)
|
|
80
|
+
expect(store.load('draft')).toBeUndefined()
|
|
81
|
+
|
|
82
|
+
store.save('draft', { n: 1 })
|
|
83
|
+
vi.setSystemTime(new Date('2026-03-03T10:00:00Z'))
|
|
84
|
+
expect(store.rename('draft', 'keeper')).toBe(true)
|
|
85
|
+
expect(store.rename('draft', 'ghost')).toBe(false)
|
|
86
|
+
|
|
87
|
+
const keeper = store.load<{ n: number }>('keeper')
|
|
88
|
+
expect(keeper?.name).toBe('keeper')
|
|
89
|
+
expect(keeper?.updatedAt).toBe('2026-03-03T10:00:00.000Z')
|
|
90
|
+
expect(keeper?.doc).toEqual({ n: 1 })
|
|
91
|
+
expect(storage.map.has('acme:draft')).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('migrates older envelopes instead of reading them wrong', () => {
|
|
95
|
+
const storage = memoryStorage()
|
|
96
|
+
storage.map.set(
|
|
97
|
+
'acme:old',
|
|
98
|
+
JSON.stringify({ v: 0, name: 'old', updatedAt: 'x', doc: { a: 1 } }),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const migrated = createConfigStore({
|
|
102
|
+
key: 'acme',
|
|
103
|
+
storage,
|
|
104
|
+
migrate: (doc) => ({ ...(doc as object), migrated: true }),
|
|
105
|
+
})
|
|
106
|
+
const loaded = migrated.load<{ a: number; migrated: boolean }>('old')
|
|
107
|
+
expect(loaded?.v).toBe(1)
|
|
108
|
+
expect(loaded?.doc).toEqual({ a: 1, migrated: true })
|
|
109
|
+
|
|
110
|
+
// Without a migrate, a version mismatch is unreadable, not corrupted.
|
|
111
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
112
|
+
const strict = createConfigStore({ key: 'acme', storage })
|
|
113
|
+
expect(strict.load('old')).toBeUndefined()
|
|
114
|
+
expect(warn).toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('caps documents at 64KB with a loud dev warning', () => {
|
|
118
|
+
const storage = memoryStorage()
|
|
119
|
+
const store = createConfigStore({ key: 'acme', storage })
|
|
120
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
121
|
+
|
|
122
|
+
const huge = 'x'.repeat(CONFIG_DOCUMENT_LIMIT + 1)
|
|
123
|
+
expect(store.save('huge', { data: huge })).toBe(false)
|
|
124
|
+
expect(storage.map.has('acme:huge')).toBe(false)
|
|
125
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
126
|
+
|
|
127
|
+
// Just under the cap still saves.
|
|
128
|
+
expect(store.save('big', { data: 'y' })).toBe(true)
|
|
129
|
+
expect(store.load('big')).toBeDefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('drops writes when the backend fails, warning once per failure site', () => {
|
|
133
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
134
|
+
const failing: ConfigStorage = {
|
|
135
|
+
getItem: () => null,
|
|
136
|
+
setItem: () => {
|
|
137
|
+
throw new Error('QuotaExceeded')
|
|
138
|
+
},
|
|
139
|
+
removeItem: () => {},
|
|
140
|
+
keys: () => [],
|
|
141
|
+
}
|
|
142
|
+
const store = createConfigStore({ key: 'acme', storage: failing })
|
|
143
|
+
expect(store.save('one', { n: 1 })).toBe(false)
|
|
144
|
+
expect(warn).toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('falls back to memory under SSR, where localStorage is absent', () => {
|
|
148
|
+
vi.stubGlobal('localStorage', undefined)
|
|
149
|
+
try {
|
|
150
|
+
const store = createConfigStore({ key: 'acme' })
|
|
151
|
+
expect(store.save('page', { n: 1 })).toBe(true)
|
|
152
|
+
expect(store.load<{ n: number }>('page')?.doc.n).toBe(1)
|
|
153
|
+
} finally {
|
|
154
|
+
vi.unstubAllGlobals()
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
})
|