@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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. 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
+ })
@@ -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
+ })