@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,285 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import { createRoot, type JSX } from 'solid-js'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ createDashboardState,
6
+ DashboardProvider,
7
+ type DashboardState,
8
+ type DashboardStateOptions,
9
+ useDashboardState,
10
+ } from './dashboard'
11
+ import type { UrlStateAdapter } from './url-state'
12
+
13
+ /** A fixed clock: Sunday 15 March 2026, 14:30 local — Q1, a leap year. */
14
+ const NOW = new Date(2026, 2, 15, 14, 30)
15
+
16
+ function setup(options: DashboardStateOptions = {}) {
17
+ let dispose!: () => void
18
+ let state!: DashboardState
19
+ createRoot((d) => {
20
+ dispose = d
21
+ state = createDashboardState({ now: () => NOW, ...options })
22
+ })
23
+ return { state, dispose }
24
+ }
25
+
26
+ function adapter(
27
+ search = '',
28
+ ): UrlStateAdapter & { last: () => URLSearchParams } {
29
+ let last = new URLSearchParams(search)
30
+ return {
31
+ search: () => search,
32
+ replace: vi.fn((params: URLSearchParams) => {
33
+ last = params
34
+ }),
35
+ last: () => last,
36
+ }
37
+ }
38
+
39
+ /** Let queued microtasks (the per-tick `replace`) run. */
40
+ async function flushMicrotasks(): Promise<void> {
41
+ await Promise.resolve()
42
+ await Promise.resolve()
43
+ }
44
+
45
+ const endOfToday = new Date(2026, 2, 15, 23, 59, 59, 999)
46
+
47
+ describe('createDashboardState — time range', () => {
48
+ it('defaults to the current 7 days, ending at today', () => {
49
+ const { state, dispose } = setup()
50
+ const range = state.timeRange()
51
+ expect(range.preset).toBe('7d')
52
+ expect(range.from).toEqual(new Date(2026, 2, 9))
53
+ expect(range.to).toEqual(endOfToday)
54
+ dispose()
55
+ })
56
+
57
+ it('computes 30d, qtd and ytd from the same clock', () => {
58
+ const { state, dispose } = setup()
59
+
60
+ state.setTimeRange('30d')
61
+ expect(state.timeRange()).toEqual({
62
+ preset: '30d',
63
+ from: new Date(2026, 1, 14),
64
+ to: endOfToday,
65
+ })
66
+
67
+ state.setTimeRange('qtd')
68
+ expect(state.timeRange()).toEqual({
69
+ preset: 'qtd',
70
+ from: new Date(2026, 0, 1),
71
+ to: endOfToday,
72
+ })
73
+
74
+ state.setTimeRange('ytd')
75
+ expect(state.timeRange()).toEqual({
76
+ preset: 'ytd',
77
+ from: new Date(2026, 0, 1),
78
+ to: endOfToday,
79
+ })
80
+ dispose()
81
+ })
82
+
83
+ it('floors qtd to the quarter, not the year', () => {
84
+ let dispose!: () => void
85
+ let state!: DashboardState
86
+ createRoot((d) => {
87
+ dispose = d
88
+ state = createDashboardState({
89
+ now: () => new Date(2026, 6, 11, 9, 0),
90
+ })
91
+ })
92
+ state.setTimeRange('qtd')
93
+ expect(state.timeRange().from).toEqual(new Date(2026, 6, 1))
94
+ dispose()
95
+ })
96
+
97
+ it('marks set dates as custom, and a preset as not', () => {
98
+ const { state, dispose } = setup()
99
+ state.setTimeRange({
100
+ from: new Date(2026, 4, 1),
101
+ to: new Date(2026, 4, 8),
102
+ })
103
+ expect(state.timeRange()).toEqual({
104
+ preset: 'custom',
105
+ from: new Date(2026, 4, 1),
106
+ to: new Date(2026, 4, 8),
107
+ })
108
+
109
+ state.setTimeRange('30d')
110
+ expect(state.timeRange().preset).toBe('30d')
111
+ dispose()
112
+ })
113
+
114
+ it('keeps the other bound when a custom range sets only one', () => {
115
+ const { state, dispose } = setup()
116
+ const to = state.timeRange().to
117
+ state.setTimeRange({ from: new Date(2026, 1, 1) })
118
+ expect(state.timeRange()).toEqual({
119
+ preset: 'custom',
120
+ from: new Date(2026, 1, 1),
121
+ to,
122
+ })
123
+ dispose()
124
+ })
125
+
126
+ it('ignores an unknown preset', () => {
127
+ const { state, dispose } = setup()
128
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
129
+ state.setTimeRange('fortnight' as '7d')
130
+ expect(state.timeRange().preset).toBe('7d')
131
+ expect(warn).toHaveBeenCalled()
132
+ warn.mockRestore()
133
+ dispose()
134
+ })
135
+ })
136
+
137
+ describe('createDashboardState — cross filter', () => {
138
+ it('holds one slot: a new value replaces, the same value clears', () => {
139
+ const { state, dispose } = setup()
140
+ expect(state.cross()).toBeNull()
141
+
142
+ state.setCross({ column: 'region', value: 'EMEA' })
143
+ expect(state.cross()).toEqual({ column: 'region', value: 'EMEA' })
144
+
145
+ state.setCross({ column: 'region', value: 'APAC' })
146
+ expect(state.cross()).toEqual({ column: 'region', value: 'APAC' })
147
+
148
+ // The same click twice is a toggle, not a second filter.
149
+ state.setCross({ column: 'region', value: 'APAC' })
150
+ expect(state.cross()).toBeNull()
151
+
152
+ state.setCross({ column: 'stage', value: 2 })
153
+ state.setCross(null)
154
+ expect(state.cross()).toBeNull()
155
+ dispose()
156
+ })
157
+ })
158
+
159
+ describe('createDashboardState — filters', () => {
160
+ it('reads declared filters, keeps defaults, and takes ad hoc keys', () => {
161
+ const { state, dispose } = setup({
162
+ filters: { region: 'EMEA', period: undefined },
163
+ })
164
+ expect(state.filters()).toEqual({ region: 'EMEA', period: undefined })
165
+
166
+ state.setFilter('period', 30)
167
+ expect(state.filters()['period']).toBe(30)
168
+
169
+ // An undeclared key still works and still serializes.
170
+ state.setFilter('source', 'web')
171
+ expect(state.filters()['source']).toBe('web')
172
+ expect(state.params().get('f:source')).toBe('web')
173
+
174
+ // `undefined` returns a declared key to its default, not to nothing.
175
+ state.setFilter('region', undefined)
176
+ expect(state.filters()['region']).toBe('EMEA')
177
+ dispose()
178
+ })
179
+
180
+ it('applies setFilters as a merge', () => {
181
+ const { state, dispose } = setup({ filters: { region: 'EMEA' } })
182
+ state.setFilters({ region: 'APAC', stage: 'won' })
183
+ expect(state.filters()).toEqual({ region: 'APAC', stage: 'won' })
184
+ dispose()
185
+ })
186
+ })
187
+
188
+ describe('createDashboardState — URL sync', () => {
189
+ it('deep-links: the URL seeds the state on creation', () => {
190
+ const { state, dispose } = setup({
191
+ adapter: adapter(
192
+ '?range=30d&f:region=EMEA&cross=%7B%22column%22%3A%22region%22%2C%22value%22%3A%22EMEA%22%7D',
193
+ ),
194
+ filters: { region: '' },
195
+ })
196
+ expect(state.timeRange().preset).toBe('30d')
197
+ expect(state.filters()['region']).toBe('EMEA')
198
+ expect(state.cross()).toEqual({ column: 'region', value: 'EMEA' })
199
+ dispose()
200
+ })
201
+
202
+ it('writes state back and prunes what is only the default', async () => {
203
+ const bridge = adapter()
204
+ const { state, dispose } = setup({
205
+ adapter: bridge,
206
+ filters: { region: '' },
207
+ })
208
+
209
+ state.setTimeRange('30d')
210
+ state.setFilter('region', 'EMEA')
211
+ state.setCross({ column: 'stage', value: 'won' })
212
+ await flushMicrotasks()
213
+
214
+ const params = bridge.last()
215
+ expect(params.get('range')).toBe('30d')
216
+ expect(params.get('f:region')).toBe('EMEA')
217
+ expect(JSON.parse(params.get('cross') ?? '')).toEqual({
218
+ column: 'stage',
219
+ value: 'won',
220
+ })
221
+
222
+ // Back to the default preset: the param goes away rather than say nothing.
223
+ state.setTimeRange('7d')
224
+ await flushMicrotasks()
225
+ expect(bridge.last().get('range')).toBeNull()
226
+ dispose()
227
+ })
228
+
229
+ it('clears the custom dates when a preset returns', async () => {
230
+ const bridge = adapter()
231
+ const { state, dispose } = setup({ adapter: bridge })
232
+
233
+ state.setTimeRange({ from: new Date(2026, 3, 1), to: new Date(2026, 3, 5) })
234
+ await flushMicrotasks()
235
+ expect(bridge.last().get('from')).toBe(new Date(2026, 3, 1).toISOString())
236
+
237
+ state.setTimeRange('qtd')
238
+ await flushMicrotasks()
239
+ expect(bridge.last().get('from')).toBeNull()
240
+ expect(bridge.last().get('to')).toBeNull()
241
+ dispose()
242
+ })
243
+
244
+ it('touches no URL when sync is off, but still serializes params', async () => {
245
+ const bridge = adapter()
246
+ const { state, dispose } = setup({ adapter: bridge, sync: false })
247
+ state.setTimeRange('30d')
248
+ await flushMicrotasks()
249
+ expect(bridge.replace).not.toHaveBeenCalled()
250
+ expect(state.params().get('range')).toBe('30d')
251
+ dispose()
252
+ })
253
+
254
+ it('treats a custom range whose dates no longer parse as a reset', () => {
255
+ const { state, dispose } = setup({
256
+ adapter: adapter('?range=custom&from=not-a-date&to=also-no'),
257
+ })
258
+ expect(state.timeRange().preset).toBe('custom')
259
+ expect(state.timeRange().from).toEqual(new Date(2026, 2, 9))
260
+ dispose()
261
+ })
262
+ })
263
+
264
+ describe('useDashboardState', () => {
265
+ function Widget(): JSX.Element {
266
+ const dashboard = useDashboardState()
267
+ return <p>{dashboard.timeRange().preset}</p>
268
+ }
269
+
270
+ it('reads the state published by the provider', () => {
271
+ const { state, dispose } = setup()
272
+ state.setTimeRange('ytd')
273
+ render(() => (
274
+ <DashboardProvider state={state}>
275
+ <Widget />
276
+ </DashboardProvider>
277
+ ))
278
+ expect(screen.getByText('ytd')).toBeTruthy()
279
+ dispose()
280
+ })
281
+
282
+ it('names the provider when used outside one', () => {
283
+ expect(() => render(() => <Widget />)).toThrow(/DashboardProvider/)
284
+ })
285
+ })
@@ -0,0 +1,381 @@
1
+ import {
2
+ type Accessor,
3
+ createContext,
4
+ createMemo,
5
+ createSignal,
6
+ type JSX,
7
+ untrack,
8
+ useContext,
9
+ } from 'solid-js'
10
+ import {
11
+ defaultsFor,
12
+ encodeValue,
13
+ pruneDefaults,
14
+ withPruning,
15
+ } from './url-serialize'
16
+ import {
17
+ createUrlState,
18
+ type UrlStateAdapter,
19
+ type UrlStateValue,
20
+ } from './url-state'
21
+
22
+ /**
23
+ * The connective tissue of an analytics dashboard: one shared time range,
24
+ * one shared filter record, and one cross-filter slot that every widget
25
+ * reads and writes.
26
+ *
27
+ * `createDashboardState` owns the signals; `DashboardProvider` and
28
+ * `useDashboardState` keep charts, KPI cards and tables from prop-drilling
29
+ * them through the widget grid. What is reproducible by link — the preset,
30
+ * the custom dates, the cross-filter, every declared filter — round-trips
31
+ * through `createUrlState` when the app supplies an adapter, and stays a
32
+ * plain signal without one, so tests and SSR stay clean.
33
+ */
34
+
35
+ /** The named ranges the kit ships. `'custom'` means the user set the dates. */
36
+ export type TimeRangePreset = '7d' | '30d' | 'qtd' | 'ytd' | 'custom'
37
+
38
+ /** The shared time window every widget filters by. */
39
+ export interface TimeRange {
40
+ /** Which preset produced this range; `'custom'` means set dates. */
41
+ preset: TimeRangePreset
42
+ /** Inclusive start — local midnight of the first day. */
43
+ from: Date
44
+ /** Inclusive end — local end-of-day of the last day. */
45
+ to: Date
46
+ }
47
+
48
+ /** The one widget click other widgets filter by. */
49
+ export interface CrossFilter {
50
+ /** The column the click came from. */
51
+ column: string
52
+ /** The clicked value. */
53
+ value: string | number | boolean
54
+ }
55
+
56
+ /** Options for {@link createDashboardState}. */
57
+ export interface DashboardStateOptions {
58
+ /** Initial range. Defaults to the current 7 days. */
59
+ timeRange?: { preset?: TimeRangePreset; from?: Date; to?: Date }
60
+ /**
61
+ * The shared filter record. Declared keys get their own URL params
62
+ * (`f:<key>`); the empty string means unset.
63
+ */
64
+ filters?: Record<string, UrlStateValue | undefined>
65
+ /** Router bridge. Present (and `sync` not false), state round-trips to the URL. */
66
+ adapter?: UrlStateAdapter
67
+ /** Turn URL writing off while still reading the adapter for deep links. */
68
+ sync?: boolean
69
+ /** Clock seam for tests. Defaults to `() => new Date()`. */
70
+ now?: () => Date
71
+ }
72
+
73
+ /** The handle {@link createDashboardState} returns. */
74
+ export interface DashboardState {
75
+ /** The current window. Preset ranges are computed from `now()` on read. */
76
+ timeRange(): TimeRange
77
+ /**
78
+ * `'7d' | '30d' | 'qtd' | 'ytd'` recomputes the window; a partial with
79
+ * dates marks the range `'custom'`. An unknown preset is ignored.
80
+ */
81
+ setTimeRange(next: TimeRangePreset | Partial<TimeRange>): void
82
+ /** The shared filters, declared and ad hoc. Unset keys read `undefined`. */
83
+ filters(): Record<string, UrlStateValue | undefined>
84
+ /** One filter. `undefined` returns it to its declared default. */
85
+ setFilter(key: string, value: UrlStateValue | undefined): void
86
+ /** Apply several filters at once — a merge, not a replacement. */
87
+ setFilters(next: Record<string, UrlStateValue | undefined>): void
88
+ /** The active cross-filter, or `null`. One slot, one value. */
89
+ cross(): CrossFilter | null
90
+ /**
91
+ * Set the cross-filter, replacing whatever held the slot. Setting the same
92
+ * column and value again clears it — the chip the widget's own click
93
+ * toggles — and `null` clears it outright.
94
+ */
95
+ setCross(next: CrossFilter | null): void
96
+ /** The params this state serializes to right now (for "copy link"). */
97
+ params(): URLSearchParams
98
+ }
99
+
100
+ const PRESET_NAMES: readonly TimeRangePreset[] = [
101
+ '7d',
102
+ '30d',
103
+ 'qtd',
104
+ 'ytd',
105
+ 'custom',
106
+ ]
107
+
108
+ function startOfDay(date: Date): Date {
109
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate())
110
+ }
111
+
112
+ function endOfDay(date: Date): Date {
113
+ return new Date(
114
+ date.getFullYear(),
115
+ date.getMonth(),
116
+ date.getDate(),
117
+ 23,
118
+ 59,
119
+ 59,
120
+ 999,
121
+ )
122
+ }
123
+
124
+ /** The window a named preset stands for, ending with today. */
125
+ function presetRange(
126
+ preset: Exclude<TimeRangePreset, 'custom'>,
127
+ now: Date,
128
+ ): { from: Date; to: Date } {
129
+ const to = endOfDay(now)
130
+ switch (preset) {
131
+ case '7d':
132
+ return {
133
+ from: startOfDay(
134
+ new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6),
135
+ ),
136
+ to,
137
+ }
138
+ case '30d':
139
+ return {
140
+ from: startOfDay(
141
+ new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29),
142
+ ),
143
+ to,
144
+ }
145
+ case 'qtd':
146
+ return {
147
+ from: startOfDay(
148
+ new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1),
149
+ ),
150
+ to,
151
+ }
152
+ case 'ytd':
153
+ return { from: startOfDay(new Date(now.getFullYear(), 0, 1)), to }
154
+ }
155
+ }
156
+
157
+ function isPreset(value: unknown): value is TimeRangePreset {
158
+ return (
159
+ typeof value === 'string' &&
160
+ (PRESET_NAMES as readonly string[]).includes(value)
161
+ )
162
+ }
163
+
164
+ function isCrossFilter(value: unknown): value is CrossFilter {
165
+ if (value === null || typeof value !== 'object') return false
166
+ const candidate = value as Partial<CrossFilter>
167
+ return (
168
+ typeof candidate.column === 'string' &&
169
+ (typeof candidate.value === 'string' ||
170
+ typeof candidate.value === 'number' ||
171
+ typeof candidate.value === 'boolean')
172
+ )
173
+ }
174
+
175
+ /**
176
+ * Create the shared dashboard state. Call it once, at the root of the
177
+ * dashboard, and hand the result to `DashboardProvider`; widgets read it
178
+ * through `useDashboardState`.
179
+ */
180
+ export function createDashboardState(
181
+ options: DashboardStateOptions = {},
182
+ ): DashboardState {
183
+ const now = options.now ?? (() => new Date())
184
+ const sync = options.sync ?? options.adapter !== undefined
185
+ const adapter = sync ? options.adapter : undefined
186
+
187
+ // A `'custom'` seed without both dates is not a range; fall back to 7d
188
+ // rather than invent one.
189
+ const seededFrom = options.timeRange?.from
190
+ const seededTo = options.timeRange?.to
191
+ const seededCustom =
192
+ options.timeRange?.preset === 'custom' &&
193
+ seededFrom !== undefined &&
194
+ seededTo !== undefined
195
+ const seedPreset: TimeRangePreset =
196
+ options.timeRange?.preset === 'custom' && !seededCustom
197
+ ? '7d'
198
+ : (options.timeRange?.preset ?? '7d')
199
+
200
+ const declared = Object.keys(options.filters ?? {})
201
+ const urlInitial: Record<string, UrlStateValue> = {
202
+ range: seedPreset,
203
+ from: seededCustom && seededFrom ? seededFrom.toISOString() : '',
204
+ to: seededCustom && seededTo ? seededTo.toISOString() : '',
205
+ cross: '',
206
+ }
207
+ for (const key of declared) {
208
+ urlInitial[`f:${key}`] = options.filters?.[key] ?? ''
209
+ }
210
+ const defaults = defaultsFor(urlInitial)
211
+
212
+ const url = createUrlState(urlInitial, {
213
+ ...(adapter ? { adapter: withPruning(adapter, defaults) } : {}),
214
+ })
215
+
216
+ // Reading `createUrlState`'s record by key runs into the
217
+ // `noUncheckedIndexedAccess` wall; this keeps every accessor honest.
218
+ const read = (key: string): UrlStateValue => {
219
+ const accessor = (
220
+ url as unknown as Record<string, Accessor<UrlStateValue> | undefined>
221
+ )[key]
222
+ return accessor ? accessor() : ''
223
+ }
224
+
225
+ // Undeclared filter keys arrive at runtime (a cross-filter writing a new
226
+ // column, say). They live in one signal of their own and still serialize.
227
+ const [extraFilters, setExtraFilters] = createSignal<
228
+ Record<string, UrlStateValue>
229
+ >({})
230
+
231
+ const timeRange = createMemo<TimeRange>(() => {
232
+ const preset = read('range')
233
+ if (isPreset(preset) && preset !== 'custom') {
234
+ return { preset, ...presetRange(preset, now()) }
235
+ }
236
+ const from = Date.parse(String(read('from')))
237
+ const to = Date.parse(String(read('to')))
238
+ if (!Number.isNaN(from) && !Number.isNaN(to)) {
239
+ return { preset: 'custom', from: new Date(from), to: new Date(to) }
240
+ }
241
+ // A custom range whose dates no longer parse is a stale link: reset the
242
+ // window, keep the label, do not crash.
243
+ return { preset: 'custom', ...presetRange('7d', now()) }
244
+ })
245
+
246
+ function setTimeRange(next: TimeRangePreset | Partial<TimeRange>): void {
247
+ if (typeof next === 'string') {
248
+ if (!isPreset(next)) {
249
+ console.warn(
250
+ `[newui-data] createDashboardState: "${next}" is not a time-range preset; ignored.`,
251
+ )
252
+ return
253
+ }
254
+ url.set('range', next)
255
+ if (next !== 'custom') {
256
+ url.set('from', undefined)
257
+ url.set('to', undefined)
258
+ }
259
+ return
260
+ }
261
+ const preset = next.preset ?? 'custom'
262
+ if (isPreset(preset) && preset !== 'custom') {
263
+ url.set('range', preset)
264
+ url.set('from', undefined)
265
+ url.set('to', undefined)
266
+ return
267
+ }
268
+ const current = untrack(timeRange)
269
+ const from = next.from ?? current.from
270
+ const to = next.to ?? current.to
271
+ url.set('range', 'custom')
272
+ url.set('from', from.toISOString())
273
+ url.set('to', to.toISOString())
274
+ }
275
+
276
+ const cross = createMemo<CrossFilter | null>(() => {
277
+ const raw = String(read('cross'))
278
+ if (raw === '') return null
279
+ try {
280
+ const parsed = JSON.parse(raw) as unknown
281
+ return isCrossFilter(parsed) ? parsed : null
282
+ } catch {
283
+ return null
284
+ }
285
+ })
286
+
287
+ function setCross(next: CrossFilter | null): void {
288
+ if (next === null) {
289
+ url.set('cross', undefined)
290
+ return
291
+ }
292
+ const current = untrack(cross)
293
+ // One slot: the same click twice is a toggle, not a second filter.
294
+ if (
295
+ current !== null &&
296
+ current.column === next.column &&
297
+ current.value === next.value
298
+ ) {
299
+ url.set('cross', undefined)
300
+ return
301
+ }
302
+ url.set('cross', JSON.stringify(next))
303
+ }
304
+
305
+ function filterValue(key: string): UrlStateValue | undefined {
306
+ const raw = declared.includes(key)
307
+ ? read(`f:${key}`)
308
+ : (extraFilters()[key] ?? '')
309
+ return raw === '' ? undefined : raw
310
+ }
311
+
312
+ const filters = createMemo(() => {
313
+ const out: Record<string, UrlStateValue | undefined> = {}
314
+ for (const key of declared) out[key] = filterValue(key)
315
+ for (const key of Object.keys(extraFilters())) {
316
+ if (!declared.includes(key)) out[key] = filterValue(key)
317
+ }
318
+ return out
319
+ })
320
+
321
+ function setFilter(key: string, value: UrlStateValue | undefined): void {
322
+ if (declared.includes(key)) {
323
+ url.set(`f:${key}`, value)
324
+ return
325
+ }
326
+ setExtraFilters((extras) => ({ ...extras, [key]: value ?? '' }))
327
+ }
328
+
329
+ function setFilters(next: Record<string, UrlStateValue | undefined>): void {
330
+ for (const [key, value] of Object.entries(next)) setFilter(key, value)
331
+ }
332
+
333
+ function params(): URLSearchParams {
334
+ const out = url.params()
335
+ for (const [key, value] of Object.entries(extraFilters())) {
336
+ if (value === undefined || value === '') out.delete(`f:${key}`)
337
+ else out.set(`f:${key}`, encodeValue(value))
338
+ }
339
+ return pruneDefaults(out, defaults)
340
+ }
341
+
342
+ return {
343
+ timeRange,
344
+ setTimeRange,
345
+ filters,
346
+ setFilter,
347
+ setFilters,
348
+ cross,
349
+ setCross,
350
+ params,
351
+ }
352
+ }
353
+
354
+ const DashboardContext = createContext<Accessor<DashboardState>>()
355
+
356
+ /**
357
+ * Publish one `createDashboardState` to the widget grid, so a chart three
358
+ * levels down reads the same time range without a prop on every card.
359
+ */
360
+ export function DashboardProvider(props: {
361
+ state: DashboardState
362
+ children: JSX.Element
363
+ }): JSX.Element {
364
+ const state = createMemo(() => props.state)
365
+ return (
366
+ <DashboardContext.Provider value={state}>
367
+ {props.children}
368
+ </DashboardContext.Provider>
369
+ )
370
+ }
371
+
372
+ /** The dashboard state published by the enclosing `DashboardProvider`. */
373
+ export function useDashboardState(): DashboardState {
374
+ const state = useContext(DashboardContext)
375
+ if (!state) {
376
+ throw new Error(
377
+ 'useDashboardState must be used within <DashboardProvider> (create the state with createDashboardState)',
378
+ )
379
+ }
380
+ return state()
381
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { csvText } from './download'
3
+
4
+ describe('download', () => {
5
+ it('quotes every cell and doubles inner quotes', () => {
6
+ expect(
7
+ csvText([
8
+ ['Name', 'Note'],
9
+ ['Acme, Inc', 'Said "yes"'],
10
+ ['Globex', 42],
11
+ ]),
12
+ ).toBe('"Name","Note"\r\n"Acme, Inc","Said ""yes"""\r\n"Globex","42"')
13
+ })
14
+ })