@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 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. It lives behind its own entry so importing `usd` never pulls a
55
- fetch stack, and it is app-specific — generic query needs belong on `/query`.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@talelk/newui-data",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "The logic layer: formatting, selection, undo and downloads — no components.",
6
6
  "repository": {
@@ -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 and form
5
- submit lifecycles. Use when a page needs to load a list, create/update/delete
6
- records, share a cache, or wire TanStack Form through the kit's Form and
7
- field-error contract.
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.0.0'
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
+ })
@@ -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
+ }