@adminforth/dashboard 1.4.0 → 1.4.2

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 (86) hide show
  1. package/README.md +23 -4
  2. package/custom/api/dashboardApi.ts +6 -9
  3. package/custom/model/dashboard.types.ts +60 -275
  4. package/custom/model/dashboardTopics.ts +5 -0
  5. package/custom/package.json +1 -0
  6. package/custom/runtime/DashboardGroup.vue +2 -2
  7. package/custom/runtime/DashboardPage.vue +17 -7
  8. package/custom/runtime/DashboardRuntime.vue +20 -8
  9. package/custom/runtime/WidgetRenderer.vue +1 -2
  10. package/custom/runtime/WidgetShell.vue +3 -3
  11. package/custom/skills/adminforth-dashboard/SKILL.md +2 -2
  12. package/custom/widgets/{gauge-card/GaugeCardWidget.vue → GaugeCardWidget.vue} +63 -61
  13. package/custom/widgets/{kpi-card/KpiCardWidget.vue → KpiCardWidget.vue} +35 -33
  14. package/custom/widgets/{pivot-table/PivotTableWidget.vue → PivotTableWidget.vue} +71 -68
  15. package/custom/widgets/{table/TableWidget.vue → TableWidget.vue} +5 -5
  16. package/custom/widgets/chart/{bar/BarChart.vue → BarChart.vue} +2 -2
  17. package/custom/widgets/chart/ChartWidget.vue +4 -15
  18. package/{dist/custom/widgets/chart/funnel → custom/widgets/chart}/FunnelChart.vue +80 -78
  19. package/{dist/custom/widgets/chart/line → custom/widgets/chart}/LineChart.vue +2 -2
  20. package/custom/widgets/chart/{pie/PieChart.vue → PieChart.vue} +2 -2
  21. package/{dist/custom/widgets/chart/stacked-bar → custom/widgets/chart}/StackedBarChart.vue +97 -95
  22. package/custom/widgets/chart/chart.types.ts +0 -28
  23. package/dist/custom/api/dashboardApi.d.ts +4 -8
  24. package/dist/custom/api/dashboardApi.js +2 -6
  25. package/dist/custom/api/dashboardApi.ts +6 -9
  26. package/dist/custom/composables/useElementSize.js +7 -10
  27. package/dist/custom/model/dashboard.types.d.ts +38 -32
  28. package/dist/custom/model/dashboard.types.js +4 -161
  29. package/dist/custom/model/dashboard.types.ts +60 -275
  30. package/dist/custom/model/dashboardTopics.d.ts +2 -0
  31. package/dist/custom/model/dashboardTopics.js +4 -0
  32. package/dist/custom/model/dashboardTopics.ts +5 -0
  33. package/dist/custom/package.json +1 -0
  34. package/dist/custom/queries/useDashboardConfig.d.ts +96 -96
  35. package/dist/custom/queries/useDashboardConfig.js +9 -12
  36. package/dist/custom/queries/useWidgetData.d.ts +96 -96
  37. package/dist/custom/queries/useWidgetData.js +9 -12
  38. package/dist/custom/runtime/DashboardGroup.vue +2 -2
  39. package/dist/custom/runtime/DashboardPage.vue +17 -7
  40. package/dist/custom/runtime/DashboardRuntime.vue +20 -8
  41. package/dist/custom/runtime/WidgetRenderer.vue +1 -2
  42. package/dist/custom/runtime/WidgetShell.vue +3 -3
  43. package/dist/custom/skills/adminforth-dashboard/SKILL.md +2 -2
  44. package/dist/custom/widgets/{gauge-card/GaugeCardWidget.vue → GaugeCardWidget.vue} +63 -61
  45. package/dist/custom/widgets/{kpi-card/KpiCardWidget.vue → KpiCardWidget.vue} +35 -33
  46. package/dist/custom/widgets/{pivot-table/PivotTableWidget.vue → PivotTableWidget.vue} +71 -68
  47. package/dist/custom/widgets/{table/TableWidget.vue → TableWidget.vue} +5 -5
  48. package/dist/custom/widgets/chart/{bar/BarChart.vue → BarChart.vue} +2 -2
  49. package/dist/custom/widgets/chart/ChartWidget.vue +4 -15
  50. package/{custom/widgets/chart/funnel → dist/custom/widgets/chart}/FunnelChart.vue +80 -78
  51. package/{custom/widgets/chart/line → dist/custom/widgets/chart}/LineChart.vue +2 -2
  52. package/dist/custom/widgets/chart/{pie/PieChart.vue → PieChart.vue} +2 -2
  53. package/{custom/widgets/chart/stacked-bar → dist/custom/widgets/chart}/StackedBarChart.vue +97 -95
  54. package/dist/custom/widgets/chart/chart.types.d.ts +0 -2
  55. package/dist/custom/widgets/chart/chart.types.js +1 -25
  56. package/dist/custom/widgets/chart/chart.types.ts +0 -28
  57. package/dist/custom/widgets/chart/chart.utils.js +6 -14
  58. package/dist/custom/widgets/registry.js +14 -22
  59. package/dist/endpoint/dashboard.d.ts +2 -3
  60. package/dist/endpoint/dashboard.js +12 -32
  61. package/dist/endpoint/groups.d.ts +2 -21
  62. package/dist/endpoint/groups.js +18 -16
  63. package/dist/endpoint/widgets.d.ts +0 -3
  64. package/dist/endpoint/widgets.js +27 -74
  65. package/dist/index.js +1 -3
  66. package/dist/schema/api.d.ts +2090 -511
  67. package/dist/schema/api.js +18 -15
  68. package/dist/schema/widget.d.ts +1003 -250
  69. package/dist/schema/widget.js +102 -46
  70. package/dist/services/dashboardConfigService.d.ts +0 -10
  71. package/dist/services/dashboardConfigService.js +6 -21
  72. package/dist/services/widgetDataService.js +226 -196
  73. package/endpoint/dashboard.ts +13 -46
  74. package/endpoint/groups.ts +25 -42
  75. package/endpoint/widgets.ts +36 -95
  76. package/index.ts +0 -3
  77. package/package.json +3 -3
  78. package/schema/api.ts +19 -15
  79. package/schema/widget.ts +113 -52
  80. package/services/dashboardConfigService.ts +6 -25
  81. package/services/widgetDataService.ts +304 -229
  82. package/custom/widgets/chart/histogram/HistogramChart.vue +0 -21
  83. package/dist/custom/widgets/chart/histogram/HistogramChart.vue +0 -21
  84. package/dist/services/widgetConfigValidator.d.ts +0 -8
  85. package/dist/services/widgetConfigValidator.js +0 -27
  86. package/services/widgetConfigValidator.ts +0 -61
@@ -9,9 +9,8 @@ import type {
9
9
  DashboardWidgetConfig,
10
10
  DashboardWidgetData,
11
11
  DashboardVariables,
12
- FilterExpression,
13
12
  FunnelQueryConfig,
14
- QueryAggregateOperation,
13
+ FilterExpression,
15
14
  QueryAggregateSelectItem,
16
15
  QueryCalcSelectItem,
17
16
  QueryConfig,
@@ -35,16 +34,55 @@ type DashboardWidgetFilters =
35
34
  | IAdminForthAndOrFilter
36
35
  | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
37
36
 
38
- type QueryRowGroup = {
39
- rows: Record<string, unknown>[];
40
- values: Record<string, unknown>;
37
+ type AggregateRule =
38
+ | { operation: 'count' }
39
+ | { operation: Exclude<QueryAggregateSelectItem['agg'], 'count'>; field: string };
40
+
41
+ type AggregateGroupByRule =
42
+ | {
43
+ type: 'date_trunc';
44
+ field: string;
45
+ truncation: TimeGrain;
46
+ timezone?: string;
47
+ as: string;
48
+ }
49
+ | {
50
+ type: 'field';
51
+ field: string;
52
+ as: string;
53
+ };
54
+
55
+ type AggregateResource = {
56
+ aggregate: (
57
+ filters: DashboardWidgetFilters,
58
+ aggregations: Record<string, AggregateRule>,
59
+ groupBy?: AggregateGroupByRule | AggregateGroupByRule[],
60
+ ) => Promise<Record<string, unknown>[]>;
61
+ };
62
+
63
+ type EffectiveGroupByItem = {
64
+ field: string;
65
+ as: string;
66
+ grain?: TimeGrain;
67
+ timezone?: string;
41
68
  };
42
69
 
43
- const NOW_MINUS_RE = /^(\d+)([dhw])$/;
44
70
  const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
45
71
  const LOOKUP_CALL_RE = /lookup\(\s*(\$variables(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
46
72
  const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
47
73
  const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
74
+ const FILTER_OPERATORS = {
75
+ eq: Filters.EQ,
76
+ neq: Filters.NEQ,
77
+ gt: Filters.GT,
78
+ gte: Filters.GTE,
79
+ lt: Filters.LT,
80
+ lte: Filters.LTE,
81
+ in: Filters.IN,
82
+ not_in: Filters.NOT_IN,
83
+ like: Filters.LIKE,
84
+ ilike: Filters.ILIKE,
85
+ } as const;
48
86
 
49
87
  export type WidgetDataService = {
50
88
  getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
@@ -90,12 +128,18 @@ async function getFunnelWidgetData(
90
128
  ): Promise<DashboardWidgetData> {
91
129
  const rows = await Promise.all(query.steps.map(async (step) => {
92
130
  const valueField = step.metric.as;
93
- const sourceRows = await getResourceRows(adminforth, step.resource, step.filters);
131
+ const [values = {}] = await getAggregateRows(
132
+ adminforth,
133
+ step.resource,
134
+ step.filters,
135
+ [step.metric],
136
+ [],
137
+ );
94
138
 
95
139
  const row: Record<string, unknown> = {
96
140
  name: step.name,
97
141
  resource: step.resource,
98
- [valueField]: calculateAggregate(sourceRows, step.metric),
142
+ [valueField]: values[valueField] ?? 0,
99
143
  };
100
144
 
101
145
  for (const calc of query.calcs ?? []) {
@@ -121,9 +165,14 @@ async function getQueryWidgetData(
121
165
  query: QueryConfig,
122
166
  variables: DashboardVariables,
123
167
  ): Promise<DashboardWidgetData> {
124
- const rows = await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.orderBy));
125
- const selectedRows = buildQueryRows(rows, query, variables);
126
- const orderedRows = sortRows(selectedRows, query.orderBy);
168
+ const selectedRows = isAggregateQuery(query)
169
+ ? await buildAggregateQueryRows(adminforth, query, variables)
170
+ : buildPlainQueryRows(
171
+ await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.order_by)),
172
+ query,
173
+ variables,
174
+ );
175
+ const orderedRows = sortRows(selectedRows, query.order_by);
127
176
  const slicedRows = typeof query.limit === 'number'
128
177
  ? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
129
178
  : orderedRows.slice(query.offset ?? 0);
@@ -150,85 +199,97 @@ async function getQueryWidgetData(
150
199
  async function getResourceRows(
151
200
  adminforth: IAdminForth,
152
201
  resourceId: string,
153
- filters: unknown,
202
+ filters: FilterExpression | undefined,
154
203
  sort?: IAdminForthSort | IAdminForthSort[],
155
204
  ) {
156
205
  return adminforth.resource(resourceId).list(
157
- normalizeFilters(filters),
206
+ getAdminForthFilters(filters),
158
207
  undefined,
159
208
  0,
160
209
  sort,
161
210
  );
162
211
  }
163
212
 
164
- function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
213
+ function buildPlainQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
165
214
  const select = query.select ?? getDefaultSelect(rows);
166
- const groupBy = query.groupBy ?? [];
167
-
168
- if (isAggregateQuery(query)) {
169
- return buildGroupedRows(rows, select, groupBy, variables, query.calcs);
170
- }
171
-
172
215
  return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
173
216
  }
174
217
 
175
- function buildGroupedRows(
176
- rows: Record<string, unknown>[],
177
- select: QuerySelectItem[],
178
- groupBy: QueryGroupByItem[],
218
+ async function buildAggregateQueryRows(
219
+ adminforth: IAdminForth,
220
+ query: QueryConfig,
179
221
  variables: DashboardVariables,
180
- calcs: QueryCalcSelectItem[] = [],
181
222
  ) {
182
- const groups = new Map<string, QueryRowGroup>();
183
- const effectiveGroupBy = groupBy.length
184
- ? groupBy
185
- : select.filter(isFieldSelectItem).map((item) => ({ field: item.field, as: item.as, grain: item.grain }));
223
+ const select = query.select ?? [];
224
+ const effectiveGroupBy = getEffectiveGroupBy(query.group_by, select);
225
+ const aggregateSelect = select.filter(isAggregateSelectItem);
226
+ const rows = await getAggregateRows(
227
+ adminforth,
228
+ query.resource,
229
+ query.filters,
230
+ aggregateSelect,
231
+ effectiveGroupBy,
232
+ );
233
+
234
+ return rows.map((row) => buildCalculatedRow(row, select, query.calcs, variables));
235
+ }
186
236
 
187
- if (!effectiveGroupBy.length) {
188
- const values = calculateGroupValues(rows, select, calcs, variables);
189
- return Object.keys(values).length ? [values] : [];
237
+ async function getAggregateRows(
238
+ adminforth: IAdminForth,
239
+ resourceId: string,
240
+ baseFilters: FilterExpression | undefined,
241
+ select: QueryAggregateSelectItem[],
242
+ groupBy: EffectiveGroupByItem[],
243
+ ) {
244
+ const resource = adminforth.resource(resourceId) as unknown as AggregateResource;
245
+ const groups = new Map<string, Record<string, unknown>>();
246
+ const groupByRules = groupBy.length ? groupBy.map(toAggregateGroupByRule) : undefined;
247
+ const aggregateSelectGroups = groupAggregateSelectItems(select);
248
+
249
+ if (groupBy.length) {
250
+ const groupSeedAlias = getHiddenAggregateAlias(groupBy, select);
251
+ const groupSeedRows = await resource.aggregate(
252
+ getAdminForthFilters(baseFilters),
253
+ { [groupSeedAlias]: { operation: 'count' } },
254
+ groupByRules,
255
+ );
256
+
257
+ for (const row of groupSeedRows) {
258
+ ensureAggregateGroup(groups, row, groupBy);
259
+ }
190
260
  }
191
261
 
192
- for (const row of rows) {
193
- const values = Object.fromEntries(effectiveGroupBy.map((item) => {
194
- const field = getGroupByField(item);
195
- const alias = getGroupByAlias(item);
196
- const grain = getGroupByGrain(item);
262
+ for (const filterGroup of aggregateSelectGroups) {
263
+ const rows = await resource.aggregate(
264
+ mergeFilters(baseFilters, filterGroup.filters),
265
+ Object.fromEntries(filterGroup.items.map((item) => [item.as, toAggregationRule(item)])),
266
+ groupByRules,
267
+ );
197
268
 
198
- return [alias, formatGroupValue(row[field], grain)];
199
- }));
200
- const key = JSON.stringify(values);
201
- const group = groups.get(key) ?? { values, rows: [] };
269
+ for (const row of rows) {
270
+ const values = ensureAggregateGroup(groups, row, groupBy);
202
271
 
203
- group.rows.push(row);
204
- groups.set(key, group);
272
+ for (const item of filterGroup.items) {
273
+ values[item.as] = row[item.as] ?? 0;
274
+ }
275
+ }
205
276
  }
206
277
 
207
- return Array.from(groups.values()).map((group) => ({
208
- ...group.values,
209
- ...calculateGroupValues(group.rows, select, calcs, variables, group.values),
210
- }));
278
+ if (!groups.size && !groupBy.length && select.length) {
279
+ groups.set(JSON.stringify({}), {});
280
+ }
281
+
282
+ return Array.from(groups.values(), (row) => applyAggregateDefaults(row, select));
211
283
  }
212
284
 
213
- function calculateGroupValues(
214
- rows: Record<string, unknown>[],
285
+ function buildCalculatedRow(
286
+ baseValues: Record<string, unknown>,
215
287
  select: QuerySelectItem[],
216
- calcs: QueryCalcSelectItem[],
288
+ calcs: QueryCalcSelectItem[] = [],
217
289
  variables: DashboardVariables,
218
- baseValues: Record<string, unknown> = {},
219
290
  ) {
220
291
  const values: Record<string, unknown> = { ...baseValues };
221
292
 
222
- for (const item of select) {
223
- if (isAggregateSelectItem(item)) {
224
- const filteredRows = item.filters
225
- ? rows.filter((row) => matchesFilterExpression(row, item.filters as FilterExpression))
226
- : rows;
227
-
228
- values[item.as] = calculateAggregate(filteredRows, item);
229
- }
230
- }
231
-
232
293
  for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
233
294
  values[item.as] = evaluateCalc(item.calc, values, variables);
234
295
  }
@@ -259,50 +320,6 @@ function buildPlainRow(
259
320
  return values;
260
321
  }
261
322
 
262
- function calculateAggregate(rows: Record<string, unknown>[], item: QueryAggregateSelectItem) {
263
- switch (item.agg) {
264
- case 'count':
265
- return rows.length;
266
- case 'count_distinct':
267
- return new Set(rows.map((row) => row[item.field!])).size;
268
- case 'sum':
269
- return aggregateNumbers(rows, item.field!, (values) => values.reduce((sum, value) => sum + value, 0));
270
- case 'avg':
271
- return aggregateNumbers(rows, item.field!, (values) => values.length
272
- ? values.reduce((sum, value) => sum + value, 0) / values.length
273
- : 0);
274
- case 'min':
275
- return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.min(...values) : 0);
276
- case 'max':
277
- return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.max(...values) : 0);
278
- case 'median':
279
- return aggregateNumbers(rows, item.field!, calculateMedian);
280
- default:
281
- throw new Error(`Unsupported aggregation operation: ${(item as { agg: QueryAggregateOperation }).agg}`);
282
- }
283
- }
284
-
285
- function aggregateNumbers(
286
- rows: Record<string, unknown>[],
287
- field: string,
288
- aggregate: (values: number[]) => number,
289
- ) {
290
- return aggregate(rows.map((row) => toFiniteNumber(row[field])).filter(Number.isFinite));
291
- }
292
-
293
- function calculateMedian(values: number[]) {
294
- if (!values.length) {
295
- return 0;
296
- }
297
-
298
- const sorted = [...values].sort((left, right) => left - right);
299
- const middle = Math.floor(sorted.length / 2);
300
-
301
- return sorted.length % 2
302
- ? sorted[middle]
303
- : (sorted[middle - 1] + sorted[middle]) / 2;
304
- }
305
-
306
323
  function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
307
324
  const expression = calc
308
325
  .replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
@@ -369,7 +386,7 @@ function getBackendSort(orderBy: QueryOrderByItem[] | undefined) {
369
386
 
370
387
  function getColumns(rows: Record<string, unknown>[], query: QueryConfig) {
371
388
  const selectColumns = [
372
- ...(query.groupBy ?? []).map(getGroupByAlias),
389
+ ...(query.group_by ?? []).map(getGroupByAlias),
373
390
  ...(query.select ?? []).map(getSelectAlias),
374
391
  ...(query.calcs ?? []).map((item) => item.as),
375
392
  ].filter(Boolean);
@@ -383,7 +400,7 @@ function getDefaultSelect(rows: Record<string, unknown>[]): QuerySelectItem[] {
383
400
 
384
401
  function isAggregateQuery(query: QueryConfig) {
385
402
  return Boolean(
386
- query.groupBy?.length
403
+ query.group_by?.length
387
404
  || query.select?.some((item) => isAggregateSelectItem(item)),
388
405
  );
389
406
  }
@@ -408,206 +425,264 @@ function getSelectAlias(item: QuerySelectItem) {
408
425
  return item.as;
409
426
  }
410
427
 
411
- function getGroupByField(item: QueryGroupByItem) {
412
- return typeof item === 'string' ? item : item.field;
413
- }
414
-
415
428
  function getGroupByAlias(item: QueryGroupByItem) {
416
429
  return typeof item === 'string' ? item : item.as ?? item.field;
417
430
  }
418
431
 
419
- function getGroupByGrain(item: QueryGroupByItem) {
420
- return typeof item === 'string' ? undefined : item.grain;
421
- }
422
-
423
- function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
424
- if (!grain) {
425
- return value;
432
+ function getEffectiveGroupBy(groupBy: QueryGroupByItem[] | undefined, select: QuerySelectItem[]) {
433
+ if (groupBy?.length) {
434
+ return groupBy.map((item) => normalizeGroupByItem(item));
426
435
  }
427
436
 
428
- const date = new Date(String(value));
437
+ return select
438
+ .filter(isFieldSelectItem)
439
+ .map((item) => normalizeGroupByItem({ field: item.field, as: item.as, grain: item.grain }));
440
+ }
429
441
 
430
- if (!Number.isFinite(date.getTime())) {
431
- return value;
442
+ function normalizeGroupByItem(item: QueryGroupByItem | Pick<QueryFieldSelectItem, 'field' | 'as' | 'grain'>): EffectiveGroupByItem {
443
+ if (typeof item === 'string') {
444
+ return { field: item, as: item };
432
445
  }
433
446
 
434
- if (grain === 'year') {
435
- return `${date.getUTCFullYear()}`;
436
- }
447
+ return {
448
+ field: item.field,
449
+ as: item.as ?? item.field,
450
+ grain: item.grain,
451
+ timezone: 'timezone' in item ? item.timezone : undefined,
452
+ };
453
+ }
437
454
 
438
- if (grain === 'quarter') {
439
- return `${date.getUTCFullYear()}-Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
455
+ function toAggregateGroupByRule(item: EffectiveGroupByItem): AggregateGroupByRule {
456
+ if (item.grain) {
457
+ return {
458
+ type: 'date_trunc',
459
+ field: item.field,
460
+ truncation: item.grain,
461
+ timezone: item.timezone,
462
+ as: item.as,
463
+ };
440
464
  }
441
465
 
442
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
466
+ return {
467
+ type: 'field',
468
+ field: item.field,
469
+ as: item.as,
470
+ };
471
+ }
443
472
 
444
- if (grain === 'month') {
445
- return `${date.getUTCFullYear()}-${month}`;
473
+ function toAggregationRule(item: QueryAggregateSelectItem): AggregateRule {
474
+ switch (item.agg) {
475
+ case 'count':
476
+ return { operation: 'count' };
477
+ case 'count_distinct':
478
+ return { operation: 'count_distinct', field: item.field! };
479
+ case 'sum':
480
+ return { operation: 'sum', field: item.field! };
481
+ case 'avg':
482
+ return { operation: 'avg', field: item.field! };
483
+ case 'min':
484
+ return { operation: 'min', field: item.field! };
485
+ case 'max':
486
+ return { operation: 'max', field: item.field! };
487
+ case 'median':
488
+ return { operation: 'median', field: item.field! };
446
489
  }
490
+ }
447
491
 
448
- const day = String(date.getUTCDate()).padStart(2, '0');
492
+ function extractAggregateGroupValues(row: Record<string, unknown>, groupBy: EffectiveGroupByItem[]) {
493
+ return Object.fromEntries(groupBy.map((item) => [
494
+ item.as,
495
+ formatGroupValue(row[item.as], item.grain),
496
+ ]));
497
+ }
449
498
 
450
- if (grain === 'week') {
451
- const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
452
- weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
453
- return weekStart.toISOString().slice(0, 10);
454
- }
499
+ function ensureAggregateGroup(
500
+ groups: Map<string, Record<string, unknown>>,
501
+ row: Record<string, unknown>,
502
+ groupBy: EffectiveGroupByItem[],
503
+ ) {
504
+ const groupValues = groupBy.length ? extractAggregateGroupValues(row, groupBy) : {};
505
+ const key = JSON.stringify(groupValues);
506
+ const existingGroup = groups.get(key);
455
507
 
456
- if (grain === 'day') {
457
- return `${date.getUTCFullYear()}-${month}-${day}`;
508
+ if (existingGroup) {
509
+ return existingGroup;
458
510
  }
459
511
 
460
- const hour = String(date.getUTCHours()).padStart(2, '0');
461
- return `${date.getUTCFullYear()}-${month}-${day}T${hour}:00:00.000Z`;
512
+ groups.set(key, groupValues);
513
+ return groupValues;
462
514
  }
463
515
 
464
- function normalizeFilters(filters: unknown): DashboardWidgetFilters {
465
- if (Array.isArray(filters)) {
466
- return filters.map((filter) => normalizeFilterNode(filter)) as DashboardWidgetFilters;
467
- }
468
-
469
- if (isRecord(filters)) {
470
- return normalizeFilterNode(filters);
516
+ function applyAggregateDefaults(values: Record<string, unknown>, select: QueryAggregateSelectItem[]) {
517
+ for (const item of select) {
518
+ if (typeof values[item.as] === 'undefined') {
519
+ values[item.as] = 0;
520
+ }
471
521
  }
472
522
 
473
- return [];
523
+ return values;
474
524
  }
475
525
 
476
- function normalizeFilterNode(filter: unknown): IAdminForthSingleFilter | IAdminForthAndOrFilter {
477
- if (!isRecord(filter)) {
478
- return filter as IAdminForthSingleFilter;
479
- }
526
+ function groupAggregateSelectItems(select: QueryAggregateSelectItem[]) {
527
+ const groups = new Map<string, {
528
+ filters: DashboardWidgetFilters;
529
+ items: QueryAggregateSelectItem[];
530
+ }>();
480
531
 
481
- if (Array.isArray(filter.and)) {
482
- return Filters.AND(filter.and.map((item) => normalizeFilterNode(item)));
483
- }
532
+ for (const item of select) {
533
+ const filters = getAdminForthFilters(item.filters);
534
+ const key = getFilterCacheKey(filters);
535
+ const group = groups.get(key) ?? { filters, items: [] };
484
536
 
485
- if (Array.isArray(filter.or)) {
486
- return Filters.OR(filter.or.map((item) => normalizeFilterNode(item)));
537
+ group.items.push(item);
538
+ groups.set(key, group);
487
539
  }
488
540
 
489
- if (typeof filter.field === 'string') {
490
- if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
491
- return Filters.EQ(filter.field, normalizeFilterValue(filter.eq));
492
- }
541
+ return Array.from(groups.values());
542
+ }
493
543
 
494
- if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
495
- return Filters.NEQ(filter.field, normalizeFilterValue(filter.neq));
496
- }
544
+ function getFilterCacheKey(filters: DashboardWidgetFilters) {
545
+ if (Array.isArray(filters) && !filters.length) {
546
+ return '__base__';
547
+ }
497
548
 
498
- if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
499
- return Filters.GT(filter.field, normalizeFilterValue(filter.gt));
500
- }
549
+ return JSON.stringify(filters);
550
+ }
501
551
 
502
- if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
503
- return Filters.GTE(filter.field, normalizeFilterValue(filter.gte));
504
- }
552
+ function mergeFilters(...filters: Array<FilterExpression | DashboardWidgetFilters | undefined>) {
553
+ const merged: Array<IAdminForthSingleFilter | IAdminForthAndOrFilter> = [];
505
554
 
506
- if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
507
- return Filters.LT(filter.field, normalizeFilterValue(filter.lt));
508
- }
555
+ for (const filter of filters) {
556
+ const normalized = getAdminForthFilters(filter);
509
557
 
510
- if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
511
- return Filters.LTE(filter.field, normalizeFilterValue(filter.lte));
558
+ if (Array.isArray(normalized)) {
559
+ merged.push(...normalized);
560
+ continue;
512
561
  }
513
562
 
514
- if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
515
- return Filters.IN(filter.field, normalizeFilterValue(filter.in));
563
+ if (normalized) {
564
+ merged.push(normalized);
516
565
  }
566
+ }
517
567
 
518
- if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
519
- return Filters.NOT_IN(filter.field, normalizeFilterValue(filter.not_in));
520
- }
568
+ if (!merged.length) {
569
+ return [] as DashboardWidgetFilters;
521
570
  }
522
571
 
523
- return filter as IAdminForthSingleFilter | IAdminForthAndOrFilter;
572
+ return merged.length === 1 ? merged[0] : merged;
524
573
  }
525
574
 
526
- function matchesFilterExpression(row: Record<string, unknown>, filter: FilterExpression): boolean {
527
- if (Array.isArray(filter)) {
528
- return filter.every((item) => matchesFilterExpression(row, item));
529
- }
575
+ function getHiddenAggregateAlias(groupBy: EffectiveGroupByItem[], select: QueryAggregateSelectItem[]) {
576
+ const usedAliases = new Set([
577
+ ...groupBy.map((item) => item.as),
578
+ ...select.map((item) => item.as),
579
+ ]);
580
+ let alias = '__adminforth_dashboard_group_seed__';
530
581
 
531
- if ('and' in filter) {
532
- return filter.and.every((item) => matchesFilterExpression(row, item));
582
+ while (usedAliases.has(alias)) {
583
+ alias = `_${alias}`;
533
584
  }
534
585
 
535
- if ('or' in filter) {
536
- return filter.or.some((item) => matchesFilterExpression(row, item));
586
+ return alias;
587
+ }
588
+
589
+ function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
590
+ if (!grain) {
591
+ return value;
537
592
  }
538
593
 
539
- const value = row[filter.field];
594
+ const date = new Date(String(value));
540
595
 
541
- if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
542
- return value === normalizeFilterValue(filter.eq);
596
+ if (!Number.isFinite(date.getTime())) {
597
+ return value;
543
598
  }
544
599
 
545
- if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
546
- return value !== normalizeFilterValue(filter.neq);
600
+ if (grain === 'year') {
601
+ return `${date.getUTCFullYear()}`;
547
602
  }
548
603
 
549
- if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
550
- return compareComparableValues(value, normalizeFilterValue(filter.gt)) > 0;
604
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
605
+
606
+ if (grain === 'month') {
607
+ return `${date.getUTCFullYear()}-${month}`;
551
608
  }
552
609
 
553
- if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
554
- return compareComparableValues(value, normalizeFilterValue(filter.gte)) >= 0;
610
+ const day = String(date.getUTCDate()).padStart(2, '0');
611
+
612
+ if (grain === 'week') {
613
+ const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
614
+ weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
615
+ return weekStart.toISOString().slice(0, 10);
555
616
  }
556
617
 
557
- if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
558
- return compareComparableValues(value, normalizeFilterValue(filter.lt)) < 0;
618
+ if (grain === 'day') {
619
+ return `${date.getUTCFullYear()}-${month}-${day}`;
559
620
  }
560
621
 
561
- if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
562
- return compareComparableValues(value, normalizeFilterValue(filter.lte)) <= 0;
622
+ return value;
623
+ }
624
+
625
+ function getAdminForthFilters(filters: FilterExpression | DashboardWidgetFilters | undefined): DashboardWidgetFilters {
626
+ if (Array.isArray(filters)) {
627
+ return filters.map((filter) => isDashboardFilterExpression(filter)
628
+ ? toAdminForthFilter(filter)
629
+ : filter);
563
630
  }
564
631
 
565
- if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
566
- return filter.in?.includes(value) ?? false;
632
+ if (isDashboardFilterExpression(filters)) {
633
+ return toAdminForthFilter(filters);
567
634
  }
568
635
 
569
- if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
570
- return !(filter.not_in?.includes(value) ?? false);
636
+ if (filters) {
637
+ return filters;
571
638
  }
572
639
 
573
- return true;
640
+ return [];
574
641
  }
575
642
 
576
- function compareComparableValues(left: unknown, right: unknown) {
577
- const leftNumber = Number(left);
578
- const rightNumber = Number(right);
643
+ function isDashboardFilterExpression(value: unknown): value is FilterExpression {
644
+ if (Array.isArray(value)) {
645
+ return true;
646
+ }
579
647
 
580
- if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
581
- return leftNumber - rightNumber;
648
+ if (!isRecord(value)) {
649
+ return false;
582
650
  }
583
651
 
584
- return String(left ?? '').localeCompare(String(right ?? ''));
652
+ return 'and' in value
653
+ || 'or' in value
654
+ || 'eq' in value
655
+ || 'neq' in value
656
+ || 'gt' in value
657
+ || 'gte' in value
658
+ || 'lt' in value
659
+ || 'lte' in value
660
+ || 'in' in value
661
+ || 'not_in' in value
662
+ || 'like' in value
663
+ || 'ilike' in value;
585
664
  }
586
665
 
587
- function normalizeFilterValue(value: unknown) {
588
- if (!isRecord(value) || typeof value.now_minus !== 'string') {
589
- return value;
666
+ function toAdminForthFilter(filter: FilterExpression): IAdminForthSingleFilter | IAdminForthAndOrFilter {
667
+ if (Array.isArray(filter)) {
668
+ return Filters.AND(filter.map((item) => toAdminForthFilter(item)));
590
669
  }
591
670
 
592
- const match = value.now_minus.match(NOW_MINUS_RE);
593
-
594
- if (!match) {
595
- return value;
671
+ if ('and' in filter) {
672
+ return Filters.AND(filter.and.map((item) => toAdminForthFilter(item)));
596
673
  }
597
674
 
598
- const amount = Number(match[1]);
599
- const unit = match[2];
600
- const date = new Date();
675
+ if ('or' in filter) {
676
+ return Filters.OR(filter.or.map((item) => toAdminForthFilter(item)));
677
+ }
601
678
 
602
- if (unit === 'h') {
603
- date.setHours(date.getHours() - amount);
604
- } else if (unit === 'w') {
605
- date.setDate(date.getDate() - amount * 7);
606
- } else {
607
- date.setDate(date.getDate() - amount);
679
+ for (const [operator, createFilter] of Object.entries(FILTER_OPERATORS)) {
680
+ if (Object.prototype.hasOwnProperty.call(filter, operator)) {
681
+ return createFilter(filter.field, filter[operator as keyof typeof FILTER_OPERATORS]);
682
+ }
608
683
  }
609
684
 
610
- return date.toISOString();
685
+ return Filters.AND([]);
611
686
  }
612
687
 
613
688
  function toFiniteNumber(value: unknown) {