@mixd-id/web-scaffold 0.1.240411125 → 0.1.240411126

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mixd-id/web-scaffold",
3
3
  "private": false,
4
- "version": "0.1.240411125",
4
+ "version": "0.1.240411126",
5
5
  "scripts": {
6
6
  "dev": "vite serve",
7
7
  "build": "vite build",
@@ -33,6 +33,7 @@
33
33
  "./listpage1": "./src/utils/listpage1.js",
34
34
  "./dashboard": "./src/utils/dashboard.js",
35
35
  "./ai-dashboard": "./src/utils/ai-dashboard.js",
36
+ "./ai-dashboard-query": "./src/utils/ai-dashboard-query.js",
36
37
  "./ai-dashboard-chat": "./src/utils/ai-dashboard-chat.mjs",
37
38
  "./ai-dashboard-views": {
38
39
  "import": "./src/utils/ai-dashboard-views.mjs"
@@ -165,9 +165,13 @@ export default {
165
165
 
166
166
  const nested = (kind === 'pie' || kind === 'doughnut') && this.data.nested && typeof this.data.nested === 'object' ? this.data.nested : null
167
167
  let rows = this.rows.map(r => ({ label: `${r.label}`, value: Number(r.value) || 0, other: !!r.other }))
168
+ // Past eight slices the rest is folded into Other, or left out with `groupOther: false`.
169
+ if(round && query.groupOther === false) rows = rows.filter(r => !r.other)
168
170
  if(round && rows.length > 8){
169
- const top = [ ...rows ].sort((a, b) => b.value - a.value).slice(0, 7)
170
- rows = [ ...top, { label: 'Other', value: rows.reduce((sum, r) => sum + r.value, 0) - top.reduce((sum, r) => sum + r.value, 0), other: true } ]
171
+ const sorted = [ ...rows ].sort((a, b) => b.value - a.value)
172
+ const top = sorted.slice(0, query.groupOther === false ? 8 : 7)
173
+ rows = query.groupOther === false ? top
174
+ : [ ...top, { label: 'Other', value: rows.reduce((sum, r) => sum + r.value, 0) - top.reduce((sum, r) => sum + r.value, 0), other: true } ]
171
175
  }
172
176
  const stacks = round ? [] : this.stacks
173
177
  const stacked = stacks.length > 0
@@ -24,7 +24,7 @@
24
24
  <div class="flex flex-row gap-2">
25
25
  <div class="flex-1 min-w-0">
26
26
  <label class="text-xs text-text-400">Chart</label>
27
- <Dropdown :model-value="node.chart || 'bar'" @update:model-value="node.chart = $event; $emit('change')">
27
+ <Dropdown :model-value="node.chart || 'bar'" @update:model-value="setChart($event)">
28
28
  <option value="bar">Bar</option>
29
29
  <option value="line">Line</option>
30
30
  <option value="pie">Pie</option>
@@ -135,24 +135,24 @@
135
135
  </Checkbox>
136
136
  </div>
137
137
 
138
- <div v-if="!measurable">
139
- <label class="text-xs text-text-400">Aggregation</label>
140
- <Dropdown v-model="query.aggregation" @change="$emit('change')">
141
- <option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
142
- </Dropdown>
143
- </div>
144
-
145
- <template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
146
- <div>
147
- <label class="text-xs text-text-400">Field</label>
148
- <Dropdown v-model="query.field" @change="$emit('change')">
149
- <option value="">— select —</option>
150
- <option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
138
+ <template v-if="node.chart === 'map'">
139
+ <div v-if="!measurable">
140
+ <label class="text-xs text-text-400">Aggregation</label>
141
+ <Dropdown v-model="query.aggregation" @change="$emit('change')">
142
+ <option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
151
143
  </Dropdown>
152
144
  </div>
153
- </template>
154
145
 
155
- <template v-if="node.chart === 'map'">
146
+ <template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
147
+ <div>
148
+ <label class="text-xs text-text-400">Field</label>
149
+ <Dropdown v-model="query.field" @change="$emit('change')">
150
+ <option value="">— select —</option>
151
+ <option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
152
+ </Dropdown>
153
+ </div>
154
+ </template>
155
+
156
156
  <div>
157
157
  <label class="text-xs text-text-400">Location from</label>
158
158
  <Dropdown :model-value="'latField' in query ? 'coords' : 'name'" @update:model-value="setLocation">
@@ -197,6 +197,12 @@
197
197
  <div class="flex-1 min-w-0">
198
198
  <div class="flex flex-row items-center h-5">
199
199
  <label class="flex-1 min-w-0 truncate text-xs text-text-400">{{ axisLabels?.group ?? 'Group by' }}</label>
200
+ <label v-if="[ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)" class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer"
201
+ title="Past eight groups, fold the rest into one Other slice">
202
+ <input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="query.groupOther !== false"
203
+ @change="$event.target.checked ? delete query.groupOther : query.groupOther = false; $emit('change')" />
204
+ Other
205
+ </label>
200
206
  </div>
201
207
  <Dropdown v-model="query.groupBy" @change="groupType !== 'date' && (query.dateModifier = '', delete query.weekOfMonth); groupType !== 'number' && delete query.bucketSize; $emit('change')">
202
208
  <option value="">— select —</option>
@@ -230,7 +236,7 @@
230
236
  </Dropdown>
231
237
  </div>
232
238
 
233
- <div v-if="groupType === 'number'" class="flex-1 min-w-0">
239
+ <div v-if="groupType === 'number' && ![ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)" class="w-[90px]">
234
240
  <div class="flex flex-row items-center h-5">
235
241
  <label class="flex-1 min-w-0 truncate text-xs text-text-400">Range size</label>
236
242
  <label v-if="query.bucketSize" class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer" title="Show empty buckets">
@@ -239,10 +245,27 @@
239
245
  Empty
240
246
  </label>
241
247
  </div>
242
- <Textbox v-model.number="query.bucketSize" type="tel" item-class="text-right" placeholder="Exact values" @change="$emit('change')" />
248
+ <Textbox v-model.number="query.bucketSize" type="tel" item-class="text-right" placeholder="" @change="$emit('change')" />
243
249
  </div>
244
250
  </div>
245
251
 
252
+ <div v-if="!measurable">
253
+ <label class="text-xs text-text-400">Aggregation</label>
254
+ <Dropdown v-model="query.aggregation" @change="$emit('change')">
255
+ <option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
256
+ </Dropdown>
257
+ </div>
258
+
259
+ <template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
260
+ <div>
261
+ <label class="text-xs text-text-400">Field</label>
262
+ <Dropdown v-model="query.field" @change="$emit('change')">
263
+ <option value="">— select —</option>
264
+ <option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
265
+ </Dropdown>
266
+ </div>
267
+ </template>
268
+
246
269
  <div v-if="!(query.extraFields ?? []).length" class="flex flex-row gap-2">
247
270
  <div class="flex-1 min-w-0">
248
271
  <div class="flex flex-row items-center h-5">
@@ -470,6 +493,15 @@ export default {
470
493
  if (!allowed.includes(row.aggregation)) row.aggregation = allowed.includes('sum') ? 'sum' : allowed[0]
471
494
  this.writeFields(rows)
472
495
  },
496
+ // Round charts slice by exact value, so a number range size does not apply to them.
497
+ setChart(chart) {
498
+ this.node.chart = chart
499
+ if ([ 'pie', 'doughnut', 'polarArea' ].includes(chart) && this.query.bucketSize != null) {
500
+ delete this.query.bucketSize
501
+ if (!this.query.dateModifier) delete this.query.fillEmpty
502
+ }
503
+ this.$emit('change')
504
+ },
473
505
  // Two or more fields draw one line each, which a split cannot share.
474
506
  writeFields(rows) {
475
507
  const [ main, ...extras ] = rows
@@ -12,7 +12,7 @@
12
12
  <option value="" disabled>field</option>
13
13
  <option v-for="fl in fields" :key="fl.key" :value="fl.key">{{ fl.label }}</option>
14
14
  </Dropdown>
15
- <Dropdown v-model="f.op" class="w-[64px]" @change="onOp(f)">
15
+ <Dropdown v-model="f.op" :class="opLabel(f.op).length > 3 ? 'w-[110px]' : 'w-[64px]'" @change="onOp(f)">
16
16
  <option v-for="o in opsFor(f.field, f.op)" :key="o" :value="o">{{ opLabel(o) }}</option>
17
17
  </Dropdown>
18
18
  <Dropdown v-if="f.op === 'period'" class="flex-1"
@@ -26,6 +26,7 @@
26
26
  <option value="lastMonth">Last month</option>
27
27
  <option value="custom">Custom date</option>
28
28
  </Dropdown>
29
+ <div v-else-if="[ 'between', 'exceptBetween', 'weekday', 'monthOfYear' ].includes(f.op)" class="flex-1"></div>
29
30
  <Dropdown v-else-if="typeOf(f.field) === 'date'" class="flex-1"
30
31
  :model-value="isToken(f.value) ? f.value : 'custom'"
31
32
  @update:model-value="setDate(f, $event)">
@@ -38,19 +39,27 @@
38
39
  <option value="" disabled>value</option>
39
40
  <option v-for="o in enumOptions(f.field)" :key="o.value" :value="`${o.value}`">{{ o.label }}</option>
40
41
  </Dropdown>
41
- <Textbox v-else v-model="f.value" class="flex-1" placeholder="value" @change="$emit('change')" />
42
+ <Textbox v-else v-model="f.value" class="flex-1" :placeholder="[ 'in', 'notIn' ].includes(f.op) ? 'a, b, c' : 'value'" @change="$emit('change')" />
42
43
  <button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove filter" @click="filters.splice(fi, 1); $emit('change')">
43
44
  <svg width="11" height="11" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
44
45
  </button>
45
46
  </div>
46
- <div v-if="f.op === 'period' && /\.\./.test(f.value)" class="flex flex-row items-center gap-1 ml-[16px] mr-[19px]">
47
+ <div v-if="(f.op === 'period' && /\.\./.test(f.value)) || [ 'between', 'exceptBetween' ].includes(f.op)" class="flex flex-row items-center gap-1 ml-[16px] mr-[19px]">
47
48
  <Datepicker mode="popup" class="flex-1" :model-value="f.value.split('..')[0]"
48
49
  @update:model-value="f.value = `${$event}..${f.value.split('..')[1]}`; $emit('change')" />
49
50
  <span class=" text-text-400 mx-1">to</span>
50
51
  <Datepicker mode="popup" class="flex-1" :model-value="f.value.split('..')[1]"
51
52
  @update:model-value="f.value = `${f.value.split('..')[0]}..${$event}`; $emit('change')" />
52
53
  </div>
53
- <Datepicker v-if="f.op !== 'period' && typeOf(f.field) === 'date' && !isToken(f.value)" mode="popup"
54
+ <div v-if="[ 'weekday', 'monthOfYear' ].includes(f.op)" class="flex flex-row flex-wrap gap-x-3 gap-y-1 ml-[16px] mr-[19px]">
55
+ <label v-for="[ value, label ] in f.op === 'weekday' ? WEEKDAYS : MONTHS" :key="value"
56
+ class="flex flex-row items-center gap-1 text-xs text-text-400 cursor-pointer">
57
+ <input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="listOf(f.value).includes(value)"
58
+ @change="toggleList(f, value, $event.target.checked)" />
59
+ {{ label }}
60
+ </label>
61
+ </div>
62
+ <Datepicker v-if="!SPECIAL_DATE_OPS.includes(f.op) && typeOf(f.field) === 'date' && !isToken(f.value)" mode="popup"
54
63
  :model-value="f.value" @update:model-value="f.value = $event; $emit('change')" />
55
64
  </div>
56
65
  </template>
@@ -82,6 +91,15 @@ export default {
82
91
 
83
92
  emits: [ 'change' ],
84
93
 
94
+ data() {
95
+ return {
96
+ SPECIAL_DATE_OPS: [ 'period', 'between', 'exceptBetween', 'weekday', 'monthOfYear' ],
97
+ // Stored as MySQL WEEKDAY() / MONTH() numbers: Monday = 0, January = 1.
98
+ WEEKDAYS: [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ].map((label, i) => [ `${i}`, label ]),
99
+ MONTHS: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ].map((label, i) => [ `${i + 1}`, label ]),
100
+ }
101
+ },
102
+
85
103
  computed: {
86
104
  filters() {
87
105
  return Array.isArray(this.query.filters) ? this.query.filters : []
@@ -125,12 +143,27 @@ export default {
125
143
  },
126
144
  opsFor(key, current) {
127
145
  const type = this.typeOf(key)
128
- if (type === 'date') return [ 'period', ...([ '=', '!=' ].includes(current) ? [ current ] : []), '>', '>=', '<', '<=' ]
129
- if (type === 'number') return [ '=', '!=', '>', '>=', '<', '<=' ]
130
- return [ '=', '!=', 'contains', 'in' ]
146
+ if (type === 'date') return [ 'period', ...([ '=', '!=' ].includes(current) ? [ current ] : []), '>', '>=', '<', '<=', 'between', 'except', 'exceptBetween', 'weekday', 'monthOfYear' ]
147
+ if (type === 'number') return [ '=', '!=', '>', '>=', '<', '<=', 'in', 'notIn' ]
148
+ return [ '=', '!=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn' ]
131
149
  },
132
150
  opLabel(op) {
133
- return { '=': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤', contains: 'has', in: 'in', period: 'is' }[op] ?? op
151
+ return {
152
+ '=': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤', contains: 'has', in: 'in', notIn: 'not in',
153
+ startsWith: 'starts with', endsWith: 'ends with', period: 'is', between: 'between', except: 'except',
154
+ exceptBetween: 'except between', weekday: 'weekday', monthOfYear: 'month',
155
+ }[op] ?? op
156
+ },
157
+ listOf(value) {
158
+ return `${value ?? ''}`.split(',').map(v => v.trim()).filter(Boolean)
159
+ },
160
+ toggleList(filter, value, checked) {
161
+ const order = (filter.op === 'weekday' ? this.WEEKDAYS : this.MONTHS).map(([ v ]) => v)
162
+ const next = new Set(this.listOf(filter.value))
163
+ if (checked) next.add(value)
164
+ else next.delete(value)
165
+ filter.value = order.filter(v => next.has(v)).join(',')
166
+ this.$emit('change')
134
167
  },
135
168
  addFilter() {
136
169
  if (!Array.isArray(this.query.filters)) this.query.filters = []
@@ -142,11 +175,30 @@ export default {
142
175
  if (!allowed.includes(filter.op)) filter.op = allowed[0]
143
176
  this.onOp(filter)
144
177
  },
178
+ // Keeps the value when it still fits the new op, otherwise starts it at a sensible default.
145
179
  onOp(filter) {
146
- const isPeriod = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth|\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2})$/.test(`${filter.value}`)
147
- if (filter.op === 'period' && !isPeriod) filter.value = 'today'
148
- if (filter.op !== 'period' && isPeriod) filter.value = ''
149
- if (filter.op !== 'period' && this.typeOf(filter.field) === 'date' && !this.isToken(filter.value) && !/^\d{4}-\d{2}-\d{2}/.test(`${filter.value}`)) filter.value = '@today'
180
+ const value = `${filter.value ?? ''}`
181
+ const day = (d) => `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, '0')}-${`${d.getDate()}`.padStart(2, '0')}`
182
+ const today = new Date()
183
+ const range = /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/
184
+ const dateLike = this.isToken(value) || /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)
185
+ const isPeriod = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth)$/.test(value) || range.test(value)
186
+ if (filter.op === 'period') {
187
+ if (!isPeriod) filter.value = 'today'
188
+ }
189
+ else if ([ 'between', 'exceptBetween' ].includes(filter.op)) {
190
+ if (!range.test(value)) filter.value = `${day(new Date(today.getFullYear(), today.getMonth(), 1))}..${day(today)}`
191
+ }
192
+ else if (filter.op === 'weekday') {
193
+ if (!/^[0-6](,[0-6])*$/.test(value)) filter.value = '0,1,2,3,4'
194
+ }
195
+ else if (filter.op === 'monthOfYear') {
196
+ if (!/^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$/.test(value)) filter.value = `${today.getMonth() + 1}`
197
+ }
198
+ else if (this.typeOf(filter.field) === 'date') {
199
+ if (!dateLike) filter.value = '@today'
200
+ }
201
+ else if (isPeriod || this.isToken(value)) filter.value = ''
150
202
  this.$emit('change')
151
203
  },
152
204
  setPeriod(filter, value) {
@@ -83,12 +83,31 @@
83
83
  </Card>
84
84
  </div>
85
85
 
86
- <div>
87
- <label class="text-xs text-text-400 mb-1">Breakdown by (first field)</label>
88
- <Dropdown v-model="query.breakdownBy" @change="$emit('change')">
89
- <option value="">— none —</option>
90
- <option v-for="f in fields.filter(f => f.type !== 'date')" :key="f.key" :value="f.key">{{ f.label }}</option>
91
- </Dropdown>
86
+ <div class="flex flex-row gap-2">
87
+ <div class="flex-1 min-w-0">
88
+ <div class="flex flex-row items-center h-5">
89
+ <label class="flex-1 min-w-0 truncate text-xs text-text-400">Breakdown by (first field)</label>
90
+ </div>
91
+ <Dropdown v-model="query.breakdownBy" @change="setBreakdownBy">
92
+ <option value="">— none —</option>
93
+ <option v-for="f in fields.filter(f => f.type !== 'date')" :key="f.key" :value="f.key">{{ f.label }}</option>
94
+ </Dropdown>
95
+ </div>
96
+
97
+ <div v-if="query.breakdownBy" class="w-[100px] min-w-0">
98
+ <div class="flex flex-row items-center h-5">
99
+ <label class="flex-1 min-w-0 truncate text-xs text-text-400" title="Top values shown in the bar, plus Other">Limit</label>
100
+ <label class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer" title="Show Other">
101
+ <input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="query.breakdownOther !== false"
102
+ @change="$event.target.checked ? delete query.breakdownOther : query.breakdownOther = false; $emit('change')" />
103
+ Other
104
+ </label>
105
+ </div>
106
+ <Dropdown :model-value="query.breakdownLimit || 4"
107
+ @update:model-value="Number($event) === 4 ? delete query.breakdownLimit : query.breakdownLimit = Number($event); $emit('change')">
108
+ <option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
109
+ </Dropdown>
110
+ </div>
92
111
  </div>
93
112
  <AIDashboardDetailSetting :owner="query" :fields="fields" :datasources="datasources" pick-field collapsible @change="$emit('change')" />
94
113
  </template>
@@ -206,6 +225,13 @@ export default {
206
225
  else delete this.query.extraFields
207
226
  this.$emit('change')
208
227
  },
228
+ setBreakdownBy() {
229
+ if (!this.query.breakdownBy) {
230
+ delete this.query.breakdownLimit
231
+ delete this.query.breakdownOther
232
+ }
233
+ this.$emit('change')
234
+ },
209
235
  extraLabel(extra) {
210
236
  const field = this.fields.find(f => f.key === extra.field)?.label
211
237
  if (extra.aggregation === 'count' || !field) return this.aggLabel(extra.aggregation)
@@ -30,7 +30,7 @@ export function buildInstruction(catalog) {
30
30
  '- Any widget or nested group inside a grid group may take `"colSpan": 2-8` (how many of the group\'s columns it spans, at most `cols`) and `"rowSpan": 2-4` (how many rows tall it is). Use them for a wide widget (a map, a long time series or a pivot table across a whole row: `colSpan` = the group\'s `cols`) or a tall one beside smaller ones (a chart with `rowSpan` 2 next to two stacked charts). Leave them out for one cell.',
31
31
  '- `{ "name", "type": "metric", "query", "fieldFormats" }` — a single KPI number.',
32
32
  ' - `fieldFormats` (optional) sets how each of the metric\'s numbers is shown, one entry per field in order — the main field first, then each `extraFields` entry; `null` keeps a field as it is. An entry is `{ "size": "xs"|"sm"|"lg"|"xl", "type": "number"|"percent"|"date"|"datetime", ... }`, every key optional: `size` is the text size (left out: medium); `number` takes `decimals` (0-6), `prefix`, `suffix` and `"compact": true` for the short form (1.2M); `percent` takes `decimals`, `prefix`, `suffix` and `"fraction": true` when the value is a fraction to multiply by 100 (0.25 → 25%); `date` / `datetime` (for a `min` / `max` of a date field) take `format`, a pattern built from YYYY YY MMMM MMM MM M DD D dddd ddd HH H hh h mm ss A, with [brackets] around literal words ("D MMM YYYY HH:mm"). Left out (no `type`), the number is shown plainly with thousands separators. `prefix` and `suffix` are any text written before or after the number, units and words alike — "add the suffix claim" / "tambahkan suffix claim" is `{ "type": "number", "suffix": " claim" }` (start a word suffix with a space, end a word prefix with one); always give `type` with them. Use it for "show it in rupiah" (`prefix` "Rp "), "as a percentage", "no decimals", "short numbers", "bigger number" (`size` "lg" or "xl"), "latest order date" shown as a date. Set it only when the user asks about how a number looks, and never say a prefix, suffix, decimals, percent, short form, size or date format is impossible — answer with `update` and the metric\'s `fieldFormats`; when returning an existing metric, keep its `fieldFormats` unchanged, and when you reorder or remove its fields move or drop their entries with them.',
33
- '- `{ "name", "type": "chart", "chart": "bar"|"line"|"pie"|"doughnut"|"polarArea"|"radar"|"scatter"|"bubble", "query" }` — a chart, `bar` when left out. `line` for a trend over time; `pie` or `doughnut` for the shares of a whole and `polarArea` to compare a few groups by size (count or sum over a few groups — past eight groups the rest is folded into Other); a `pie` or `doughnut` with `splitBy` draws two rings, the inner one by `groupBy` and the outer one splitting each inner slice by `splitBy` (its top `splitLimit` plus Other) — use it for shares by two fields, "chat sessions by business and sub-business" (`groupBy` business, `splitBy` subBusiness), count or sum only and never on a date `groupBy`; never split a `polarArea`; `radar` to compare a few groups (or splitBy parts) around one circle; `scatter` and `bubble` to show one dot per group, bubble sized by its value. A bar chart takes `"horizontal": true` to lay its bars sideways — use it for many groups or long labels (campaign names, customers), or when asked. Only when the user asks for one of these kinds, or the data clearly suits it.',
33
+ '- `{ "name", "type": "chart", "chart": "bar"|"line"|"pie"|"doughnut"|"polarArea"|"radar"|"scatter"|"bubble", "query" }` — a chart, `bar` when left out. `line` for a trend over time; `pie` or `doughnut` for the shares of a whole and `polarArea` to compare a few groups by size (count or sum over a few groups — past eight groups the rest is folded into Other; `"groupOther": false` on the query shows only the top eight with no Other slice, only when asked); a `pie` or `doughnut` with `splitBy` draws two rings, the inner one by `groupBy` and the outer one splitting each inner slice by `splitBy` (its top `splitLimit` plus Other) — use it for shares by two fields, "chat sessions by business and sub-business" (`groupBy` business, `splitBy` subBusiness), count or sum only and never on a date `groupBy`; never split a `polarArea`; `radar` to compare a few groups (or splitBy parts) around one circle; `scatter` and `bubble` to show one dot per group, bubble sized by its value. A bar chart takes `"horizontal": true` to lay its bars sideways — use it for many groups or long labels (campaign names, customers), or when asked. Only when the user asks for one of these kinds, or the data clearly suits it.',
34
34
  '- Chart look, any chart kind including a map: `"color": { "mode": "single", "color": "#RRGGBB" }` paints the whole series one color (not for pie, doughnut, polarArea or a split chart); `{ "mode": "mixed", "colors": ["#RRGGBB", ...] }` gives every bar, slice, dot or split part its own color (`colors` optional, the palette when left out). Leave `color` out for the default look. `"axisMarks": "day"|"week"|"month"|"none"` (bar or line grouped by `hour`, `day` or `week`, a horizontal bar too) marks where each day, week or month starts on the date axis — separator lines, alternating bands and the period\'s name ("Oct week 3" — the week of the month its Monday falls in — or "Oct 2026") under the dates — while every bar stays one bucket; left out, it is automatic (days for hourly data over several days, weeks past three weeks of days, months past three months of days or eight weeks). Set it only when asked ("show the weeks on the axis"); for one bar per week use `dateModifier` `week` instead. `"legend": true|false` shows or hides the legend; left out, it shows for split and round charts, and for mixed or heatmap maps. Set `color` and `legend` only when the user asks about colors or the legend.',
35
35
  '- `{ "name", "type": "chart", "chart": "map", "country": "ID", "color": { "mode": "single"|"mixed"|"heatmap" }, "query" }` — a map of one country (only Indonesia, `ID`, so far) with one point per place, sized by its value. Use it for "by city", "per region", "per province", "where are our customers", "map".',
36
36
  ' - Places come either from a place name field — `groupBy`, a string field holding Indonesian city, regency or province names; names match without case or punctuation, with or without "Kota" / "Kabupaten" / "Kab." / "Provinsi", in Indonesian or English ("Jakarta Selatan", "Kota Bandung", "Jawa Timur", "West Java", "Solo", "Jogja"), and a province is placed at its capital — or from `latField` + `lngField`, number fields holding latitude and longitude, with an optional `groupBy` naming each point. Pick a name field only when its values look like place names; names that cannot be placed are counted as "not on the map".',
@@ -40,14 +40,15 @@ export function buildInstruction(catalog) {
40
40
  '- `{ "name", "type": "table", "query" }` — a data table: a list of rows, or a pivot table when `query.pivot` is set.',
41
41
  '',
42
42
  '# Query shapes (preferred — the server computes live data from them)',
43
- '- metric: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "extraFields": [{ "aggregation", "field", "label" }], "breakdownBy": <field key>, "filters" }` — `field` is required unless aggregation is `count`; `countDistinct` counts the different values of any field ("unique customers" is countDistinct of the customer field). `extraFields` (up to 4) are more fields over the same rows and filters, shown smaller after the main number as a ratio — "76 / 12,000" for a *Total Chats / Total Views* metric (main: sum of chats, extra: sum of views); name the widget after all its fields, `label` names each extra one. `breakdownBy` breaks down the main field only.',
44
- '- chart: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "groupBy": <field key>, "dateModifier": ""|"hour"|"day"|"week"|"month"|"quarter"|"year"|"hourOfDay"|"dayOfWeek"|"dayOfMonth"|"monthOfYear", "bucketSize": <number>, "limit", "sort": "labelAsc"|"labelDesc"|"valueDesc"|"valueAsc", "fillEmpty": true, "splitBy": <field key>, "splitLimit": 1-20, "splitOther": false, "breakdownBy": <field key>, "filters" }` — `aggregation` / `field` measure every bar, slice or point: left out, each one counts its rows; `sum` / `avg` need a number field, `min` / `max` a number or date field, `countDistinct` any field. Use them for "revenue per month" (sum of the amount field), "average order value by city" (avg). Set a dateModifier when grouping by a date field: `hour` for "per hour" / "hourly", `day` for "per day" / "daily", and so on. `week` with `"weekOfMonth": true` keeps one bar per week but names each by the week of the month its Monday falls in ("Oct week 2") — use it for "week of month" or "weeks named by month". The recurring ones fold every date onto one cycle: `hourOfDay` (0-23) for "busiest hour" / "by hour of day" over several days, `dayOfWeek` (Monday to Sunday) for "by weekday", `dayOfMonth`, `monthOfYear` for seasonality. `bucketSize` groups a number field into ranges that wide (order totals in steps of 100000). `sort` orders the bars (left out: dates and ranges by label, other groups largest first). `fillEmpty` shows a zero bar for every empty bucket — set it for time series and recurring buckets so gaps are visible. `splitBy` stacks every bar by another field, one colour per value (its top `splitLimit`, 1 to 20, default 7, plus "Other" — `"splitOther": false` leaves Other out, only when asked) — use it for "per day, grouped by business", "by status per month". A split bar chart stacks its parts; `"stacked": false` on the node puts them side by side instead — set it when asked to compare the parts bar by bar ("side by side", "grouped", "not stacked"). Leave it out for a single-colour chart.',
43
+ '- metric: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "extraFields": [{ "aggregation", "field", "label" }], "breakdownBy": <field key>, "breakdownLimit": 1-20, "breakdownOther": false, "filters" }` — `field` is required unless aggregation is `count`; `countDistinct` counts the different values of any field ("unique customers" is countDistinct of the customer field). `extraFields` (up to 4) are more fields over the same rows and filters, shown smaller after the main number as a ratio — "76 / 12,000" for a *Total Chats / Total Views* metric (main: sum of chats, extra: sum of views); name the widget after all its fields, `label` names each extra one. `breakdownBy` breaks down the main field only; `breakdownLimit` (default 4) sets how many top values its bar shows, `"breakdownOther": false` leaves the Other part out — only when asked.',
44
+ '- chart: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "groupBy": <field key>, "dateModifier": ""|"hour"|"day"|"week"|"month"|"quarter"|"year"|"hourOfDay"|"dayOfWeek"|"dayOfMonth"|"monthOfYear", "bucketSize": <number>, "limit", "sort": "labelAsc"|"labelDesc"|"valueDesc"|"valueAsc", "fillEmpty": true, "splitBy": <field key>, "splitLimit": 1-20, "splitOther": false, "groupOther": false, "breakdownBy": <field key>, "filters" }` — `aggregation` / `field` measure every bar, slice or point: left out, each one counts its rows; `sum` / `avg` need a number field, `min` / `max` a number or date field, `countDistinct` any field. Use them for "revenue per month" (sum of the amount field), "average order value by city" (avg). Set a dateModifier when grouping by a date field: `hour` for "per hour" / "hourly", `day` for "per day" / "daily", and so on. `week` with `"weekOfMonth": true` keeps one bar per week but names each by the week of the month its Monday falls in ("Oct week 2") — use it for "week of month" or "weeks named by month". The recurring ones fold every date onto one cycle: `hourOfDay` (0-23) for "busiest hour" / "by hour of day" over several days, `dayOfWeek` (Monday to Sunday) for "by weekday", `dayOfMonth`, `monthOfYear` for seasonality. `bucketSize` groups a number field into ranges that wide (order totals in steps of 100000) — not on a pie, doughnut or polarArea. `sort` orders the bars (left out: dates and ranges by label, other groups largest first). `fillEmpty` shows a zero bar for every empty bucket — set it for time series and recurring buckets so gaps are visible. `splitBy` stacks every bar by another field, one colour per value (its top `splitLimit`, 1 to 20, default 7, plus "Other" — `"splitOther": false` leaves Other out, only when asked) — use it for "per day, grouped by business", "by status per month". A split bar chart stacks its parts; `"stacked": false` on the node puts them side by side instead — set it when asked to compare the parts bar by bar ("side by side", "grouped", "not stacked"). Leave it out for a single-colour chart.',
45
45
  '- chart `extraFields` (line and bar only, up to 4): `[{ "aggregation", "field", "label" }]` — more measures over the same rows, groups and filters, each drawn as its own line (or bar) in its own colour with a legend; `aggregation` / `field` stay the first one. Use it when one chart should compare several numbers over the same axis — "spend, impressions, chats and sales per hour" is a `line` chart grouped by the date field with `dateModifier: "hour"`, `aggregation: "sum"`, `field` the spend field and `extraFields` sum of impressions, chats and sales. Give the first one a label too as `fieldLabel`. Each line is scaled on its own, so numbers of very different sizes all read; set `"sharedAxis": true` on the node only when they share a unit and should be compared directly. It cannot be combined with `splitBy`; `breakdownBy` and a click break down the first field.',
46
46
  '- `detail` — cards with more about one value, e.g. a creative\'s image, title, body and link: `{ "fields": [{ "key", "type": "text"|"longText"|"number"|"date"|"datetime"|"image"|"link", "label", "format" }], "groupBy": <field key> }` reads the widget\'s own rows, one card per `groupBy` value (left out: one card, the latest row). When the details live in another datasource, give `"datasource": <its key>, "match": { "field": <this datasource\'s id field>, "to": <that datasource\'s matching field> }` instead of `groupBy`; `fields` are then that datasource\'s keys, and one card is shown per matching row (e.g. results by creative → `{ "datasource": "marketing_campaign_creative", "match": { "field": "creativeId", "to": "id" } }`). It goes on a pivot row field, or on a metric, chart or pivot table query with `"field": <the field whose values open it>` — an ⓘ then sits beside each of its values in the breakdown, and a chart grouped by that field shows Detail on the rows behind a bar. A date or date & time detail field may take `format`, a date pattern as in `fieldFormats`. Set it when asked to see or open details of a value.',
47
47
  '- `breakdownBy` (metric, chart and pivot table) — a field that is not a date; tapping the number or a bar opens a table of that value per `breakdownBy` value, every value listed. Set it when the user asks to see a widget broken down, or "per business" detail behind a number. A chart without one breaks down by its `splitBy`.',
48
48
  '- table: `{ "datasource", "columns": [{ "key", "label", "format" }], "order": [[<field key>, "asc"|"desc"], ...], "pageSize": 1-50, "filters" }` — `order` sorts by its first entry, then the next for ties (up to 5 levels); a column\'s optional `format` sets how its values are shown, the same shape as a metric\'s `fieldFormats` entry without `size` (`{ "type": "number", "prefix": "Rp " }`, `{ "type": "date", "format": "D MMM YYYY" }`); set it only when asked how a column looks, and keep it when returning the table. A column may also take `"colorScale"` (and `"colorScaleReverse": true`), the same as a pivot value\'s: its cells are tinted by rank among the table\'s rows — set it when asked to color, highlight or heat-map a column.',
49
49
  '- pivot table (a table with `pivot` instead of columns/order/pageSize): `{ "datasource", "pivot": { "rows": [{ "field", "label", "dateModifier", "bucketSize" }], "columns": [same], "values": [{ "aggregation", "field", "label", "showAs": "percentOfTotal"|"percentOfRow"|"percentOfColumn"|"percentOfParent" }], "sort": "labelDesc"|"valueDesc"|"valueAsc", "sortValue": <value index>, "grandTotals": "both"|"row"|"column"|"none", "subtotals": false }, "breakdownBy": <field key>, "filters" }` — like an Excel PivotTable: `rows` are nested down the side (each one a level with its own subtotal, collapsible), `columns` go across the top (usually one field, often a date with a dateModifier such as `month`), and every cell shows each of `values` for that row and column, with grand totals. `label` on a value reads like "Sum of Spend". `showAs` turns a value into a share (leave it out for the plain value). A row field may take a `"detail"` (see below): an ⓘ beside each of its rows opens its cards for that row — set it when asked to see the details behind a row. `sort` orders the rows within each level (left out: label A → Z; `valueDesc` / `valueAsc` = largest / smallest first by the value at index `sortValue` in `values`, 0 when left out). A value may also be calculated from two other values of the same pivot: `{ "aggregation": "calc", "label", "calc": { "a": { "aggregation", "field" }, "op": "/"|"*"|"+"|"-", "b": { "aggregation", "field" } }, "format": "percent" }` — `a` and `b` name values already in `values` (same aggregation and field); it is computed in each cell, so "impressions per spend %" is `a` sum of impressions, `op` "/", `b` sum of spend, `format` "percent". Calculated values cannot take `showAs`. Any value may take `"colorScale"`: a theme color (`primary`, `secondary`, `accent`) or a Tailwind color (`red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `slate`, `gray`) — its cells are tinted by rank among the rows of the same level in that column, strongest at the highest value, or at the lowest with `"colorScaleReverse": true` (use it for \"lower is better\", like cost per chat) — set it when asked to color, highlight or heat-map a value. When a calculated value needs a value the pivot does not have yet, add that value to `values` too. Sorting "by Meta Chats" or "by impressions per spend" is `sort` `valueDesc` (or `valueAsc` for "lowest first") with `sortValue` the index of that value. Use a pivot table for "X by Y and Z", "per campaign per month", cross-tabs or summaries with subtotals. Tapping a cell opens its breakdown by `breakdownBy`, or the rows behind it when there is none.',
50
- '- filters: `[{ "field": <field key>, "op": "="|"!="|">"|">="|"<"|"<="|"contains"|"in", "value" }]`',
50
+ '- filters: `[{ "field": <field key>, "op": "="|"!="|">"|">="|"<"|"<="|"contains"|"startsWith"|"endsWith"|"in"|"notIn", "value" }]` — `in` / `notIn` take a comma-separated list ("1,8") on text and number fields; `contains`, `startsWith`, `endsWith` are for text fields.',
51
+ '- more date ops: `between` / `exceptBetween` with `"YYYY-MM-DD..YYYY-MM-DD"` (both days included) keep or leave out a date range; `except` with one day (a date or a token like `@today`) leaves that whole day out; `weekday` keeps only those days of the week, `"value": "0,1,2,3,4"` (Monday = 0 … Sunday = 6, so "5,6" is weekends); `monthOfYear` keeps only those months of any year, `"value": "6,7,8"` (January = 1). Use `weekday` for "weekdays only" / "weekends", `monthOfYear` for "every December", `exceptBetween` for "excluding the holidays from … to …".',
51
52
  '- A field listed with `values` stores codes that each mean something ("8 = Closed"): filter on the stored code (`{ "field": "status", "op": "=", "value": 8 }` for closed sessions, `in` with "1,8" for several), never on the meaning. The dashboard shows the meanings wherever the codes appear, so use the meanings in widget names and replies.',
52
53
  '- periods — today, yesterday, this week, last week, this month, last month or a date range on a date field is ONE filter with the op `period`: `{ "field": "createdAt", "op": "period", "value": "today" | "yesterday" | "thisWeek" | "lastWeek" | "thisMonth" | "lastMonth" | "2026-10-01..2026-10-15" }` (a range includes both days: from 00:00:00 of the first day to the end of the last). It is recomputed every time the dashboard opens, so "today" stays today. One specific day ("on 15 October", "2026-10-15") is a range of that day to itself, `"2026-10-15..2026-10-15"`. Never filter a date field with `=` or `!=` — on a date-time that matches only one exact moment, and the filter editor no longer offers them for dates (it shows `period` as **is**, with Today, Yesterday, This week, Last week, This month, Last month or Custom date — a from and a to date).',
53
54
  '- other relative dates ("last 7 days", "last 30 days", "this year") — a `>=` filter whose value is a token, never a literal date: `@today`, `@startOfWeek`, `@startOfMonth`, `@startOfYear`, optionally shifted (`@today-7d`, `@today-30d`, `@startOfMonth-1m`; units d, w, m, y). Each is that day at 00:00.',
@@ -57,7 +58,7 @@ export function buildInstruction(catalog) {
57
58
  '',
58
59
  '# What the user can do on the dashboard (answer questions about it with the action `none`)',
59
60
  '- Tapping a metric, a bar, a pie slice, a map point or a pivot cell opens its breakdown by `breakdownBy` (a chart without one uses `splitBy`), and tapping a breakdown row lists the records behind it. Without a breakdown field the tap lists the records straight away.',
60
- '- A metric with `breakdownBy` also shows a thin bar under its number, split into its top four values plus Other.',
61
+ '- A metric with `breakdownBy` also shows a thin bar under its number, split into its top `breakdownLimit` values (default four) plus Other.',
61
62
  '- A metric\'s settings (Views, with the metric selected) list its fields one per row — the first is the big number, the rest are shown after it as a ratio; drag a row to the top to make it the main field. Clicking a field (or **+ Add field**) opens a card with its Field, Aggregation, **Font size** (Extra small to Extra large) and **Data type** — Auto, Number (decimal places, prefix, suffix, short form 1.2M), Percent (decimal places, prefix, suffix, value is a fraction) or Date / Date & time (a date format, or Custom… with its own pattern) — with a preview. These are the metric\'s `fieldFormats`.',
62
63
  '- The breakdown table and the table of records behind a widget each have a **Customize** button (sliders icon) that picks, orders, renames and hides their columns, sets a column\'s type (text, number, date, date & time) and date format; column widths dragged there are saved too. These are saved with the widget and kept when you change it, but you cannot set them — tell the user where they are.',
63
64
  '- A pivot table\'s row levels collapse and expand one by one or all at once; its columns can be resized by dragging their edges, and the widths are saved with the dashboard (your edits keep them).',
@@ -234,8 +235,11 @@ const querySchema = {
234
235
  splitBy: { type: 'string' },
235
236
  splitLimit: { type: 'number' },
236
237
  splitOther: { type: 'boolean' },
238
+ groupOther: { type: 'boolean' },
237
239
  weekOfMonth: { type: 'boolean' },
238
240
  breakdownBy: { type: 'string' },
241
+ breakdownLimit: { type: 'number' },
242
+ breakdownOther: { type: 'boolean' },
239
243
  detail: detailSchema,
240
244
  fieldLabel: { type: 'string' },
241
245
  extraFields: {
@@ -383,8 +387,12 @@ export const RESPONSE_SCHEMA = {
383
387
 
384
388
  const NODE_TYPES = [ 'group', 'metric', 'chart', 'table' ]
385
389
  const COLOR_SCALES = [ 'primary', 'secondary', 'accent', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'slate', 'gray' ]
386
- const FILTER_OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'in', 'period' ]
390
+ const FILTER_OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'period', 'between', 'except', 'exceptBetween', 'weekday', 'monthOfYear' ]
387
391
  const PERIOD = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth|\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2})$/
392
+ const DATE_RANGE = /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/
393
+ // Which values each op takes: date-only ops, their value shapes, and ops a date field cannot take.
394
+ const DATE_ONLY_OPS = { period: PERIOD, between: DATE_RANGE, exceptBetween: DATE_RANGE, except: /^(@[a-zA-Z]+([+-]\d+[dwmy])?|\d{4}-\d{2}-\d{2})$/, weekday: /^[0-6](,[0-6])*$/, monthOfYear: /^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$/ }
395
+ const TEXT_ONLY_OPS = [ 'contains', 'startsWith', 'endsWith' ]
388
396
  const DATE_MODIFIERS = [ '', 'hour', 'day', 'week', 'month', 'quarter', 'year', 'hourOfDay', 'dayOfWeek', 'dayOfMonth', 'monthOfYear' ]
389
397
  const SORTS = [ 'labelAsc', 'labelDesc', 'valueDesc', 'valueAsc' ]
390
398
  const DETAIL_TYPES = [ 'text', 'longText', 'number', 'date', 'datetime', 'image', 'link' ]
@@ -422,7 +430,13 @@ function sanitizeFilters(filters, ds) {
422
430
  if (!Array.isArray(filters)) return []
423
431
  return filters
424
432
  .filter(f => f && ds.fields.has(f.field) && FILTER_OPS.includes(f.op) && f.value !== '' && f.value != null)
425
- .filter(f => f.op !== 'period' || (ds.fields.get(f.field).type === 'date' && PERIOD.test(`${f.value}`)))
433
+ .filter(f => {
434
+ const type = ds.fields.get(f.field).type
435
+ if (DATE_ONLY_OPS[f.op]) return type === 'date' && DATE_ONLY_OPS[f.op].test(`${f.value}`)
436
+ if (TEXT_ONLY_OPS.includes(f.op)) return type !== 'date' && type !== 'number'
437
+ if (f.op === 'notIn') return type !== 'date'
438
+ return true
439
+ })
426
440
  .slice(0, 10)
427
441
  .map(f => ds.fields.get(f.field).type === 'date' && f.op === '=' && /^\d{4}-\d{2}-\d{2}$/.test(`${f.value}`)
428
442
  ? { field: f.field, op: 'period', value: `${f.value}..${f.value}` }
@@ -491,6 +505,10 @@ function sanitizeQuery(node, dsMap, notes) {
491
505
  if (extras.length) clean.extraFields = extras
492
506
  }
493
507
  sanitizeBreakdown(query, clean, ds, node, notes, dsMap)
508
+ if (clean.breakdownBy) {
509
+ if (query.breakdownLimit != null) clean.breakdownLimit = clampInt(query.breakdownLimit, 1, 20, 4)
510
+ if (query.breakdownOther === false) clean.breakdownOther = false
511
+ }
494
512
  return clean
495
513
  }
496
514
 
@@ -530,7 +548,7 @@ function sanitizeQuery(node, dsMap, notes) {
530
548
  const groupType = ds.fields.get(query.groupBy).type
531
549
  clean.dateModifier = groupType === 'date' && DATE_MODIFIERS.includes(query.dateModifier) ? query.dateModifier : ''
532
550
  if (clean.dateModifier === 'week' && query.weekOfMonth === true) clean.weekOfMonth = true
533
- if (groupType === 'number' && Number(query.bucketSize) > 0 && Number.isFinite(Number(query.bucketSize))) clean.bucketSize = Number(query.bucketSize)
551
+ if (groupType === 'number' && ![ 'pie', 'doughnut', 'polarArea' ].includes(node.chart) && Number(query.bucketSize) > 0 && Number.isFinite(Number(query.bucketSize))) clean.bucketSize = Number(query.bucketSize)
534
552
  if (query.limit != null) clean.limit = clampInt(query.limit, 1, 100, 30)
535
553
  if (SORTS.includes(query.sort)) clean.sort = query.sort
536
554
  if (query.fillEmpty === true && (clean.dateModifier || clean.bucketSize)) clean.fillEmpty = true
@@ -553,6 +571,7 @@ function sanitizeQuery(node, dsMap, notes) {
553
571
  }
554
572
  else notes.push(`invalid splitBy "${query.splitBy}" on "${node.name}" — drawn without a split`)
555
573
  }
574
+ if (query.groupOther === false && [ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)) clean.groupOther = false
556
575
  if (clean.splitBy && [ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)
557
576
  && (node.chart === 'polarArea' || groupType === 'date' || ![ 'count', 'sum' ].includes(clean.aggregation))) {
558
577
  notes.push(`splitBy dropped on "${node.name}" — only a count or sum pie or doughnut not grouped by a date has an outer ring`)
@@ -0,0 +1,465 @@
1
+ // ── AI dashboard query engine ────────────────────────────────────────────────
2
+ // Runs a dashboard node query (metric, chart, table, pivot) against a catalogue
3
+ // of datasources, each mapping a Sequelize model to the fields it exposes.
4
+ //
5
+ // const { createQueryEngine } = require('@mixd-id/web-scaffold/ai-dashboard-query')
6
+ // const { runQuery } = createQueryEngine({ models, datasources, canRead, fail })
7
+ // await runQuery({ datasource: 'order', type: 'chart', groupBy: 'createdAt', dateModifier: 'day' }, user)
8
+ //
9
+ // Options:
10
+ // models → { [name]: SequelizeModel }, looked up by datasource.model and field.subquery.model
11
+ // datasources → [ { key, model, aggregations, fields: [ { key, label, type, relation?, attribute?, count?, subquery?, enum? } ] } ]
12
+ // canRead → (datasource, user) => boolean, defaults to allowing all
13
+ // fail → (key, message) => never, throws the host's validation error;
14
+ // defaults to an Error carrying `errors: { [key]: [ message ] }`
15
+ // Sequelize → the host's Sequelize class; defaults to the one the models are bound to.
16
+ // It must be the host's copy, fn / col / literal are checked with instanceof
17
+
18
+ const dayjs = require('dayjs')
19
+
20
+ const TYPES = [ 'metric', 'chart', 'table', 'pivot' ]
21
+ const OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'notBetween', 'empty' ]
22
+ const DATE_FORMATS = { hour: '%Y-%m-%d %H:00', day: '%Y-%m-%d', week: '%Y-%u', month: '%Y-%m', year: '%Y' }
23
+ const RECURRING = { hourOfDay: 'HOUR', dayOfWeek: 'WEEKDAY', dayOfMonth: 'DAYOFMONTH', monthOfYear: 'MONTH' }
24
+ const DATE_MODIFIERS = [ '', ...Object.keys(DATE_FORMATS), 'quarter', ...Object.keys(RECURRING) ]
25
+ const MAX_CHART_LIMIT = 2000
26
+ const MAX_PAGE_SIZE = 100
27
+ const MAX_PIVOT_GROUPS = 5000
28
+ const MAX_PIVOT_DIMENSIONS = 4
29
+ const MAX_PIVOT_VALUES = 12
30
+
31
+ const defaultFail = (key, message) => {
32
+ const error = new Error(message)
33
+ error.errors = { [key]: [ message ] }
34
+ throw error
35
+ }
36
+
37
+ function createQueryEngine({ models, datasources, canRead = () => true, fail = defaultFail, Sequelize } = {}) {
38
+ if(!models) throw new Error('createQueryEngine needs models')
39
+ if(!Array.isArray(datasources)) throw new Error('createQueryEngine needs a datasources array')
40
+
41
+ Sequelize = Sequelize ?? Object.values(models).find(_ => _?.sequelize)?.sequelize.constructor
42
+ if(!Sequelize) throw new Error('createQueryEngine needs Sequelize, or models bound to a connection')
43
+ const { Op, fn, col, literal, where: whereOf } = Sequelize
44
+
45
+ const datasourceFor = (key, user) => {
46
+ const datasource = datasources.find(_ => _.key === key)
47
+ if(!datasource) fail('datasource', `Unknown datasource: ${key}`)
48
+ if(!canRead(datasource, user)) fail('datasource', `No access to datasource: ${key}`)
49
+ return datasource
50
+ }
51
+
52
+ const fieldOf = (datasource, key, name = 'field') => {
53
+ const field = datasource.fields.find(_ => _.key === key)
54
+ if(!field) fail(name, `Unknown field "${key}" in ${datasource.key}`)
55
+ return field
56
+ }
57
+
58
+ const columnOf = (Model, key) => Model.rawAttributes[key]?.field ?? key
59
+
60
+ // Where a field is read from: the association path to join (empty for this table), the column,
61
+ // and for this table the attribute key. `relation` may be nested ('marketingCampaign.addon').
62
+ // The last relation's own key (addonId → addon.id) is the foreign key on the table before it, so it skips that join.
63
+ const sourceOf = (Model, field) => {
64
+ if(field.count || field.subquery) return { path: [] }
65
+ if(!field.relation) return { path: [], column: columnOf(Model, field.key), key: field.key }
66
+
67
+ const segments = field.relation.split('.')
68
+ let Source = Model
69
+ for(const [ i, segment ] of segments.entries()){
70
+ const association = Source.associations[segment]
71
+ if(!association) throw new Error(`Unknown relation ${field.relation} on ${Model.name}`)
72
+
73
+ if(i === segments.length - 1){
74
+ const path = segments.slice(0, i)
75
+ if(association.associationType === 'BelongsTo' && field.attribute === association.targetKey)
76
+ return { path, column: columnOf(Source, association.foreignKey), key: path.length ? undefined : association.foreignKey }
77
+ return { path: segments, column: columnOf(association.target, field.attribute) }
78
+ }
79
+ Source = association.target
80
+ }
81
+ }
82
+
83
+ // The SQL column of a field, qualified so it stays unambiguous next to joined tables
84
+ const refOf = (Model, field) => {
85
+ if(field.count) return countOf(Model, field)
86
+ if(field.subquery) return subqueryOf(Model, field)
87
+ const { path, column } = sourceOf(Model, field)
88
+ return col(`${path.length ? path.join('->') : Model.name}.${column}`)
89
+ }
90
+
91
+ // A `count` field: the number of rows of that has-many relation per record, as a correlated subquery,
92
+ // so it never multiplies this table's rows the way a join would
93
+ const countOf = (Model, field) => {
94
+ const association = Model.associations[field.count]
95
+ if(association?.associationType !== 'HasMany') throw new Error(`${field.count} is not a has-many relation of ${Model.name}`)
96
+
97
+ const queryInterface = Model.sequelize.getQueryInterface()
98
+ const table = queryInterface.quoteIdentifier(association.target.getTableName())
99
+ const foreignKey = queryInterface.quoteIdentifier(columnOf(association.target, association.foreignKey))
100
+ const sourceKey = queryInterface.quoteIdentifiers(`${Model.name}.${columnOf(Model, association.sourceKey)}`)
101
+ return literal(`(SELECT COUNT(*) FROM ${table} WHERE ${table}.${foreignKey} = ${sourceKey})`)
102
+ }
103
+
104
+ // A `subquery` field: count or sum of another model's rows that match this record, as a correlated subquery
105
+ // like a `count` field, for links no association can express.
106
+ // { model, aggregate: 'count' | 'sum', attribute, match: { theirKey: ourKey | { key: ourKey, collate } }, range: [ theirDate, ourFrom, ourTo ], where: { theirKey: value | [ values ] } }
107
+ // `collate` converts our column to the collation of theirs, so their index is used when the two differ.
108
+ // `index` forces their index, for a match column whose statistics make the optimizer scan instead
109
+ const subqueryOf = (Model, field) => {
110
+ const { model, aggregate = 'count', attribute, match = {}, range, where = {}, index } = field.subquery
111
+ const Target = models[model]
112
+ const queryInterface = Model.sequelize.getQueryInterface()
113
+ const theirs = (key) => `sub.${queryInterface.quoteIdentifier(columnOf(Target, key))}`
114
+ const ours = (key) => queryInterface.quoteIdentifiers(`${Model.name}.${columnOf(Model, key)}`)
115
+ const escape = (value) => Model.sequelize.escape(value)
116
+ const matchOf = (our) => {
117
+ const { key, collate } = typeof our === 'string' ? { key: our } : our
118
+ if(!collate) return ours(key)
119
+ if(!/^[a-z0-9_]+$/.test(collate)) throw new Error(`Invalid collation ${collate}`)
120
+ return `CONVERT(${ours(key)} USING ${collate.split('_')[0]}) COLLATE ${collate}`
121
+ }
122
+
123
+ const conditions = [
124
+ ...Object.entries(match).map(([ their, our ]) => `${theirs(their)} = ${matchOf(our)}`),
125
+ ...(range ? [ `${theirs(range[0])} >= ${ours(range[1])}`, `${theirs(range[0])} <= ${ours(range[2])}` ] : []),
126
+ ...Object.entries(where).map(([ key, value ]) => Array.isArray(value) ?
127
+ `${theirs(key)} IN (${value.map(escape).join(', ')})` : `${theirs(key)} = ${escape(value)}`)
128
+ ]
129
+ const expr = aggregate === 'sum' ? `COALESCE(SUM(${theirs(attribute)}), 0)` : 'COUNT(*)'
130
+ const table = queryInterface.quoteIdentifier(Target.getTableName())
131
+ const hint = index ? ` FORCE INDEX (${queryInterface.quoteIdentifier(index)})` : ''
132
+ return literal(`(SELECT ${expr} FROM ${table} sub${hint}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''})`)
133
+ }
134
+
135
+ // The where key of a field: its own attribute, or $relation.column$ for a joined one
136
+ const whereKeyOf = (Model, field) => {
137
+ const { path, column, key } = sourceOf(Model, field)
138
+ return path.length ? `$${path.join('.')}.${column}$` : key
139
+ }
140
+
141
+ // Nested LEFT JOINs for the relations the given field keys use.
142
+ // Queries pass subQuery: false, or a has-many join with a LIMIT moves this table into a subquery the joined columns can't reach
143
+ const joinsFor = (datasource, keys) => {
144
+ const Model = models[datasource.model]
145
+ const include = []
146
+
147
+ for(const key of new Set(keys)){
148
+ const field = datasource.fields.find(_ => _.key === key)
149
+ if(!field) continue
150
+
151
+ let level = include
152
+ for(const association of sourceOf(Model, field).path){
153
+ let node = level.find(_ => _.association === association)
154
+ if(!node) level.push(node = { association, attributes: [], required: false, include: [] })
155
+ level = node.include
156
+ }
157
+ }
158
+
159
+ return include
160
+ }
161
+
162
+ const queryKeys = ({ field, secondary, groupBy, filters, columns, order } = {}) => [
163
+ field, secondary?.field, groupBy,
164
+ ...(Array.isArray(filters) ? filters.map(_ => _?.field) : []),
165
+ ...(Array.isArray(columns) ? columns.map(_ => typeof _ === 'string' ? _ : _?.key) : []),
166
+ ...(Array.isArray(order) ? order.map(_ => Array.isArray(_) ? _[0] : undefined) : [])
167
+ ]
168
+
169
+ const RELATIVE_UNITS = { d: 'day', day: 'day', w: 'week', week: 'week', m: 'month', month: 'month', y: 'year', year: 'year' }
170
+
171
+ const toDate = (field, value) => {
172
+ if(value === null) return value
173
+ if(value === undefined || `${value}`.trim() === '') return undefined
174
+
175
+ const text = `${value}`.trim().toLowerCase()
176
+ let date
177
+
178
+ if(text === 'now') date = dayjs()
179
+ else if(text === 'today') date = dayjs().startOf('day')
180
+ else if(text === 'yesterday') date = dayjs().subtract(1, 'day').startOf('day')
181
+ else{
182
+ const relative = text.match(/^(now|today)?\s*([+-])\s*(\d+)\s*([a-z]+?)s?$/)
183
+ if(relative && RELATIVE_UNITS[relative[4]]){
184
+ const [ , base, sign, amount ] = relative
185
+ date = (base === 'today' ? dayjs().startOf('day') : dayjs()).add((sign === '-' ? -1 : 1) * amount, RELATIVE_UNITS[relative[4]])
186
+ }
187
+ else if(typeof value === 'number' || /^\d{4}-\d{2}(-\d{2})?([ t]\d{2}:\d{2}(:\d{2})?)?/.test(text))
188
+ date = dayjs(value)
189
+ }
190
+
191
+ if(!date?.isValid())
192
+ fail('filters', `Invalid date for ${field}: "${value}". Use YYYY-MM-DD [HH:mm:ss], today, yesterday, now or a relative value like -7d / now-1 month`)
193
+
194
+ return date.format('YYYY-MM-DD HH:mm:ss')
195
+ }
196
+
197
+ const isBlank = (value) => value === undefined || value === null || `${value}`.trim() === ''
198
+
199
+ const listOf = (value) => (Array.isArray(value) ? value : `${value ?? ''}`.split(',')).map(_ => `${_}`.trim()).filter(_ => _ !== '')
200
+
201
+ const COMPARE = { '=': Op.eq, '!=': Op.ne, '>': Op.gt, '>=': Op.gte, '<': Op.lt, '<=': Op.lte, 'in': Op.in, 'notIn': Op.notIn }
202
+ const LISTS = [ 'in', 'notIn' ]
203
+
204
+ const filtersToWhere = (datasource, filters = []) => {
205
+ if(!Array.isArray(filters)) fail('filters', 'filters must be an array')
206
+ const Model = models[datasource.model]
207
+
208
+ const and = filters.map(({ field, op = '=', value, dateModifier } = {}) => {
209
+ const definition = fieldOf(datasource, field, 'filters')
210
+ const { type } = definition
211
+ const key = whereKeyOf(Model, definition)
212
+ // A count / subquery field is an expression, not a column key
213
+ const where = (operator, value) => definition.count || definition.subquery ?
214
+ whereOf(refOf(Model, definition), { [operator]: value }) : { [key]: { [operator]: value } }
215
+ if(!OPS.includes(op)) fail('filters', `Unknown operator: ${op}`)
216
+
217
+ if(op === 'empty'){
218
+ const empty = type === 'string' ? { [Op.or]: [ where(Op.is, null), where(Op.eq, '') ] } : where(Op.is, null)
219
+ return value === false || value === 'false' ? { [Op.not]: empty } : empty
220
+ }
221
+
222
+ if(isBlank(value) || (LISTS.includes(op) && !listOf(value).length)) return null
223
+ if(op === 'notBetween' && !(Array.isArray(value) && value.length === 2)) fail('filters', `notBetween needs [ from, to ] for ${field}`)
224
+
225
+ if(dateModifier){
226
+ if(!RECURRING[dateModifier]) fail('filters', `Unknown filter dateModifier: ${dateModifier}`)
227
+ if(type !== 'date') fail('filters', `dateModifier needs a date field, ${field} is ${type}`)
228
+ if(!COMPARE[op]) fail('filters', `${op} cannot be used with dateModifier`)
229
+ return whereOf(fn(RECURRING[dateModifier], refOf(Model, definition)), { [COMPARE[op]]: LISTS.includes(op) ? listOf(value) : value })
230
+ }
231
+
232
+ if(type === 'date' && ![ 'contains', 'startsWith', 'endsWith' ].includes(op)){
233
+ value = LISTS.includes(op) ? listOf(value).map(_ => toDate(field, _))
234
+ : op === 'notBetween' ? value.map(_ => toDate(field, _))
235
+ : toDate(field, value)
236
+ }
237
+
238
+ switch(op){
239
+ case 'contains': return where(Op.substring, `${value}`)
240
+ case 'startsWith': return where(Op.startsWith, `${value}`)
241
+ case 'endsWith': return where(Op.endsWith, `${value}`)
242
+ case 'in': return where(Op.in, listOf(value))
243
+ case 'notIn': return where(Op.notIn, listOf(value))
244
+ case 'notBetween': return where(Op.notBetween, value)
245
+ default: return where(COMPARE[op], value)
246
+ }
247
+ })
248
+
249
+ const conditions = and.filter(Boolean)
250
+ return conditions.length ? { [Op.and]: conditions } : {}
251
+ }
252
+
253
+ const aggregateExpr = (datasource, Model, aggregation = 'count', field) => {
254
+ if(!datasource.aggregations.includes(aggregation)) fail('aggregation', `Unknown aggregation: ${aggregation}`)
255
+
256
+ if(aggregation === 'count')
257
+ return field ? fn('COUNT', refOf(Model, fieldOf(datasource, field))) : fn('COUNT', literal('*'))
258
+
259
+ if(!field) fail('field', `${aggregation} needs a field`)
260
+ const definition = fieldOf(datasource, field)
261
+ if(aggregation === 'countDistinct') return fn('COUNT', fn('DISTINCT', refOf(Model, definition)))
262
+ const { key, type } = definition
263
+ if((aggregation === 'sum' || aggregation === 'avg') && type !== 'number')
264
+ fail('field', `${aggregation} needs a number field, ${key} is ${type}`)
265
+ if(type === 'string') fail('field', `${aggregation} needs a number or date field, ${key} is a string`)
266
+
267
+ return fn(aggregation.toUpperCase(), refOf(Model, definition))
268
+ }
269
+
270
+ const toNumber = (value) => value === null || value === undefined || value instanceof Date || isNaN(Number(value)) ? value : Number(value)
271
+
272
+ const metric = async (datasource, Model, query) => {
273
+ const { aggregation, field, filters, secondary } = query
274
+ const where = filtersToWhere(datasource, filters)
275
+
276
+ const attributes = [ [ aggregateExpr(datasource, Model, aggregation, field), 'value' ] ]
277
+ if(secondary)
278
+ attributes.push([ aggregateExpr(datasource, Model, secondary.aggregation, secondary.field), 'secondary' ])
279
+
280
+ const row = await Model.findOne({ attributes, where, include: joinsFor(datasource, queryKeys(query)), subQuery: false, raw: true })
281
+
282
+ return {
283
+ value: toNumber(row?.value ?? 0),
284
+ ...(secondary ? { secondary: { value: toNumber(row?.secondary ?? 0) } } : {})
285
+ }
286
+ }
287
+
288
+ const groupLabel = (Model, group, dateModifier, bucketSize) => {
289
+ const column = refOf(Model, group)
290
+
291
+ if(dateModifier){
292
+ if(!DATE_MODIFIERS.includes(dateModifier)) fail('dateModifier', `Unknown dateModifier: ${dateModifier}`)
293
+ if(group.type !== 'date') fail('dateModifier', `dateModifier needs a date groupBy, ${group.key} is ${group.type}`)
294
+ if(RECURRING[dateModifier]) return fn(RECURRING[dateModifier], column)
295
+ if(dateModifier === 'quarter') return fn('CONCAT', fn('YEAR', column), '-Q', fn('QUARTER', column))
296
+ return fn('DATE_FORMAT', column, DATE_FORMATS[dateModifier])
297
+ }
298
+
299
+ if(!isBlank(bucketSize)){
300
+ const size = Number(bucketSize)
301
+ if(!(size > 0)) fail('bucketSize', `bucketSize must be a positive number`)
302
+ if(group.type !== 'number') fail('bucketSize', `bucketSize needs a number groupBy, ${group.key} is ${group.type}`)
303
+ const sql = column.val ?? Model.sequelize.getQueryInterface().quoteIdentifiers(column.col)
304
+ return literal(`FLOOR(${sql} / ${size}) * ${size}`)
305
+ }
306
+
307
+ return group.type === 'date' ? fn('DATE_FORMAT', column, '%Y-%m-%d %H:%i:%s') : column
308
+ }
309
+
310
+ const chart = async (datasource, Model, query) => {
311
+ const { aggregation, field, groupBy, dateModifier = '', bucketSize, limit, filters } = query
312
+ if(!groupBy) fail('groupBy', 'chart needs groupBy')
313
+
314
+ const group = fieldOf(datasource, groupBy, 'groupBy')
315
+ const label = groupLabel(Model, group, dateModifier, bucketSize)
316
+ const ascending = !!dateModifier || !isBlank(bucketSize)
317
+
318
+ const rows = await Model.findAll({
319
+ attributes: [ [ label, 'label' ], [ aggregateExpr(datasource, Model, aggregation, field), 'value' ] ],
320
+ where: filtersToWhere(datasource, filters),
321
+ include: joinsFor(datasource, queryKeys(query)),
322
+ group: [ literal('label') ],
323
+ order: ascending ? [ [ literal('label'), 'ASC' ] ] : [ [ literal('value'), 'DESC' ] ],
324
+ limit: Math.min(Math.max(parseInt(limit) || MAX_CHART_LIMIT, 1), MAX_CHART_LIMIT),
325
+ subQuery: false,
326
+ raw: true
327
+ })
328
+
329
+ return { series: rows.map(row => ({ label: row.label, value: toNumber(row.value) })) }
330
+ }
331
+
332
+ const table = async (datasource, Model, query) => {
333
+ const { columns, order = [], page = 0, pageSize = 20, filters } = query
334
+ if(columns !== undefined && columns !== null && !Array.isArray(columns)) fail('columns', 'columns must be an array')
335
+
336
+ const cols = (columns?.length ? columns : datasource.fields).map(column => {
337
+ const key = typeof column === 'string' ? column : column?.key
338
+ const field = fieldOf(datasource, key, 'columns')
339
+ return { key: field.key, label: (typeof column === 'object' && column.label) || field.label, field }
340
+ })
341
+
342
+ if(!Array.isArray(order)) fail('order', 'order must be an array')
343
+ const sort = order.map(([ key, dir = 'asc' ] = []) => {
344
+ const field = fieldOf(datasource, key, 'order')
345
+ if(![ 'asc', 'desc' ].includes(`${dir}`.toLowerCase())) fail('order', `Unknown direction: ${dir}`)
346
+ return [ refOf(Model, field), `${dir}`.toUpperCase() ]
347
+ })
348
+
349
+ const size = Math.min(Math.max(parseInt(pageSize) || 20, 1), MAX_PAGE_SIZE)
350
+ const offset = Math.max(parseInt(page) || 0, 0) * size
351
+
352
+ const rows = await Model.findAll({
353
+ attributes: cols.map(_ => [ refOf(Model, _.field), _.key ]),
354
+ where: filtersToWhere(datasource, filters),
355
+ include: joinsFor(datasource, [ ...queryKeys({ filters, order }), ...cols.map(_ => _.key) ]),
356
+ order: sort.length ? sort : [ [ refOf(Model, fieldOf(datasource, 'id')), 'DESC' ] ],
357
+ limit: size + 1,
358
+ offset,
359
+ subQuery: false,
360
+ raw: true
361
+ })
362
+
363
+ return {
364
+ columns: cols.map(({ key, label }) => ({ key, label })),
365
+ rows: rows.slice(0, size),
366
+ hasNext: rows.length > size
367
+ }
368
+ }
369
+
370
+ // A pivot dimension (row or column), read like a chart's groupBy
371
+ const pivotDimension = (datasource, Model, name) => (dimension) => {
372
+ const field = fieldOf(datasource, dimension?.field, `pivot.${name}`)
373
+ if(sourceOf(Model, field).path.some((_, i, path) => !oneRowPer(Model, path.slice(0, i + 1))))
374
+ fail(`pivot.${name}`, `${field.key} cannot be used in a pivot, its relation can match several rows`)
375
+ return { field, expr: groupLabel(Model, field, dimension.dateModifier ?? '', dimension.bucketSize) }
376
+ }
377
+
378
+ // Whether joining this association path keeps one row per record: belongs-to a primary or unique key, or has-one
379
+ const oneRowPer = (Model, path) => {
380
+ let Source = Model
381
+ for(const segment of path){
382
+ const association = Source.associations[segment]
383
+ const { targetKey, target } = association
384
+ const toUniqueKey = association.associationType === 'BelongsTo' &&
385
+ (targetKey === target.primaryKeyAttribute || !!target.rawAttributes[targetKey]?.unique)
386
+ if(!toUniqueKey && association.associationType !== 'HasOne') return false
387
+ Source = association.target
388
+ }
389
+ return true
390
+ }
391
+
392
+ const pivotKey = (value) => value === null || value === undefined ? '' : `${value}`
393
+
394
+ // One grouped query per subtotal level, so every subtotal and grand total (avg / min / max included)
395
+ // comes from the database. Level { r, c } groups by the first r row fields and by all column fields (c > 0) or none.
396
+ const pivot = async (datasource, Model, query) => {
397
+ const { pivot: spec, filters } = query
398
+ if(!spec || typeof spec !== 'object' || Array.isArray(spec)) fail('pivot', 'pivot needs { rows, columns, values }')
399
+
400
+ const listIn = (name, max) => {
401
+ const list = spec[name] ?? []
402
+ if(!Array.isArray(list)) fail(`pivot.${name}`, `pivot.${name} must be an array`)
403
+ if(list.length > max) fail(`pivot.${name}`, `pivot.${name} allows at most ${max}`)
404
+ return list
405
+ }
406
+
407
+ const rows = listIn('rows', MAX_PIVOT_DIMENSIONS).map(pivotDimension(datasource, Model, 'rows'))
408
+ const columns = listIn('columns', MAX_PIVOT_DIMENSIONS).map(pivotDimension(datasource, Model, 'columns'))
409
+ const values = listIn('values', MAX_PIVOT_VALUES)
410
+ const measures = (values.length ? values : [ { aggregation: 'count' } ])
411
+ .map(value => ({ field: value?.field, expr: aggregateExpr(datasource, Model, value?.aggregation ?? 'count', value?.field) }))
412
+
413
+ const where = filtersToWhere(datasource, filters)
414
+ const filterKeys = queryKeys({ filters })
415
+
416
+ const levels = []
417
+ for(let r = 0; r <= rows.length; r++)
418
+ for(const c of columns.length ? [ 0, columns.length ] : [ 0 ])
419
+ levels.push({ r, c, dimensions: [ ...rows.slice(0, r), ...columns.slice(0, c) ] })
420
+
421
+ const results = await Promise.all(levels.map(({ dimensions }) => Model.findAll({
422
+ attributes: [
423
+ ...dimensions.map((_, i) => [ _.expr, `k${i}` ]),
424
+ ...measures.map((_, i) => [ _.expr, `v${i}` ])
425
+ ],
426
+ where,
427
+ include: joinsFor(datasource, [ ...dimensions.map(_ => _.field.key), ...measures.map(_ => _.field), ...filterKeys ]),
428
+ ...(dimensions.length ? { group: dimensions.map((_, i) => literal(`k${i}`)) } : {}),
429
+ limit: MAX_PIVOT_GROUPS + 1,
430
+ subQuery: false,
431
+ raw: true
432
+ })))
433
+
434
+ return {
435
+ pivot: {
436
+ levels: levels.map(({ r, c, dimensions }, i) => ({
437
+ r, c,
438
+ rows: results[i].slice(0, MAX_PIVOT_GROUPS).map(row => [
439
+ ...dimensions.map((_, k) => pivotKey(row[`k${k}`])),
440
+ ...measures.map((_, v) => toNumber(row[`v${v}`] ?? null))
441
+ ])
442
+ })),
443
+ truncated: results.some(_ => _.length > MAX_PIVOT_GROUPS)
444
+ }
445
+ }
446
+ }
447
+
448
+ const runQuery = async ({ datasource: key, type, ...query } = {}, user) => {
449
+ if(!TYPES.includes(type)) fail('type', `type must be one of ${TYPES.join(', ')}`)
450
+
451
+ const datasource = datasourceFor(key, user)
452
+ const Model = models[datasource.model]
453
+
454
+ switch(type){
455
+ case 'metric': return metric(datasource, Model, query)
456
+ case 'chart': return chart(datasource, Model, query)
457
+ case 'table': return table(datasource, Model, query)
458
+ case 'pivot': return pivot(datasource, Model, query)
459
+ }
460
+ }
461
+
462
+ return { runQuery, filtersToWhere }
463
+ }
464
+
465
+ module.exports = { createQueryEngine, TYPES, OPS, DATE_MODIFIERS }
@@ -52,10 +52,33 @@ function resolveDateToken(value, now = new Date()) {
52
52
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`
53
53
  }
54
54
 
55
+ // The filter editor's date ops, lowered to the plain ops every query engine runs:
56
+ // between / exceptBetween "YYYY-MM-DD..YYYY-MM-DD", both days included
57
+ // except one day (a date or a token), the whole day left out
58
+ // weekday / monthOfYear "0,4" — WEEKDAY() numbers (Monday = 0) / MONTH() numbers (January = 1)
55
59
  function resolveFilters(filters, now = new Date()) {
56
60
  if (!Array.isArray(filters)) return filters
61
+ const day = (value) => `${resolveDateToken(value, now) ?? ''}`.slice(0, 10)
62
+ const nextDay = (date) => resolveDateToken('@today+1d', new Date(`${date}T00:00:00`))
57
63
  return filters.flatMap(filter => {
58
- if (filter?.op !== 'period') return [ { ...filter, value: resolveDateToken(filter?.value, now) } ]
64
+ const { field, op, value } = filter ?? {}
65
+ if (op === 'weekday' || op === 'monthOfYear') {
66
+ const list = `${value ?? ''}`.split(',').map(v => v.trim()).filter(Boolean)
67
+ return list.length ? [ { field, op: 'in', value: list.join(','), dateModifier: op === 'weekday' ? 'dayOfWeek' : 'monthOfYear' } ] : []
68
+ }
69
+ if (op === 'except') {
70
+ const date = day(value)
71
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? [ { field, op: 'notBetween', value: [ `${date} 00:00:00`, `${date} 23:59:59` ] } ] : []
72
+ }
73
+ if (op === 'between' || op === 'exceptBetween') {
74
+ const range = `${value ?? ''}`.split('..').map(day)
75
+ if (range.length !== 2 || !range.every(date => /^\d{4}-\d{2}-\d{2}$/.test(date))) return []
76
+ const [ from, to ] = range[0] <= range[1] ? range : [ range[1], range[0] ]
77
+ return op === 'between'
78
+ ? [ { field, op: '>=', value: `${from} 00:00:00` }, { field, op: '<', value: nextDay(to) } ]
79
+ : [ { field, op: 'notBetween', value: [ `${from} 00:00:00`, `${to} 23:59:59` ] } ]
80
+ }
81
+ if (op !== 'period') return [ { ...filter, value: resolveDateToken(value, now) } ]
59
82
  const named = { today: [ '@today', '@tomorrow' ], yesterday: [ '@yesterday', '@today' ], thisWeek: [ '@startOfWeek', '@startOfWeek+1w' ], lastWeek: [ '@startOfWeek-1w', '@startOfWeek' ], thisMonth: [ '@startOfMonth', '@startOfMonth+1m' ], lastMonth: [ '@startOfMonth-1m', '@startOfMonth' ] }[filter.value]
60
83
  const custom = `${filter.value ?? ''}`.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/)
61
84
  if (!named && !custom) return []
@@ -66,6 +89,9 @@ function resolveFilters(filters, now = new Date()) {
66
89
  })
67
90
  }
68
91
 
92
+ // Pie, doughnut and polar area slice by exact value: a saved `bucketSize` is ignored on them.
93
+ const ROUND_CHARTS = [ 'pie', 'doughnut', 'polarArea' ]
94
+
69
95
  function bucketFilters(query, bucket) {
70
96
  if (bucket == null || bucket === '' || !query?.groupBy) return []
71
97
  const label = `${bucket}`
@@ -127,7 +153,7 @@ const AIDashboardMixin = {
127
153
  try{
128
154
  const filters = resolveFilters(copy.query.filters, now)
129
155
  const { extraFields, fieldLabel, weekOfMonth, detail, ...base } = copy.query
130
- const query = { ...base, filters }
156
+ const query = { ...base, filters, ...(ROUND_CHARTS.includes(copy.chart) ? { bucketSize: undefined } : {}) }
131
157
  let data = copy.type === 'chart' && query.latField && query.lngField
132
158
  ? await this.analyzeMapNode(query)
133
159
  : await this.analyzeNode({ node: { ...copy, query } })
@@ -148,11 +174,14 @@ const AIDashboardMixin = {
148
174
  .map(row => ({ label: row.label == null || `${row.label}` === 'null' ? '' : `${row.label}`, value: Number(row.value) || 0 }))
149
175
  .sort((a, b) => b.value - a.value)
150
176
  const additive = [ 'count', 'sum', undefined, '' ].includes(query.aggregation)
151
- const top = rows.length > 5 ? rows.slice(0, 4) : rows
177
+ const limit = Math.min(20, Math.max(1, parseInt(query.breakdownLimit) || 4))
178
+ const showOther = additive && query.breakdownOther !== false
179
+ // With Other on, one leftover value is shown itself rather than as an "Other" of one.
180
+ const top = showOther && rows.length === limit + 1 ? rows : rows.slice(0, limit)
152
181
  data.breakdown = {
153
182
  field: query.breakdownBy,
154
183
  rows: top,
155
- other: additive && rows.length > top.length ? rows.slice(top.length).reduce((sum, row) => sum + row.value, 0) : null,
184
+ other: showOther && rows.length > top.length ? rows.slice(top.length).reduce((sum, row) => sum + row.value, 0) : null,
156
185
  total: additive ? rows.reduce((sum, row) => sum + row.value, 0) : null,
157
186
  }
158
187
  }
@@ -173,11 +202,11 @@ const AIDashboardMixin = {
173
202
  },
174
203
 
175
204
  async loadBreakdown({ node, bucket, cell, value, page = 0 } = {}) {
176
- const { splitBy, splitLimit, splitOther, weekOfMonth, breakdownBy, extraFields, fieldLabel, pivot, detail, ...query } = node?.query ?? {}
205
+ const { splitBy, splitLimit, splitOther, groupOther, weekOfMonth, breakdownBy, breakdownLimit, breakdownOther, extraFields, fieldLabel, pivot, detail, ...query } = node?.query ?? {}
177
206
  const field = breakdownBy || splitBy || null
178
207
  const filters = [
179
208
  ...(resolveFilters(query.filters, new Date()) ?? []),
180
- ...(node.type === 'chart' ? bucketFilters(query, bucket) : []),
209
+ ...(node.type === 'chart' ? bucketFilters(ROUND_CHARTS.includes(node.chart) ? { ...query, bucketSize: undefined } : query, bucket) : []),
181
210
  ...(Array.isArray(cell) ? cell : []).flatMap(dim => `${dim?.value ?? ''}` === ''
182
211
  ? [ { field: dim.field, op: 'empty', value: true } ]
183
212
  : bucketFilters({ groupBy: dim.field, dateModifier: dim.dateModifier, bucketSize: dim.bucketSize }, dim.value)),
@@ -308,8 +337,9 @@ const AIDashboardMixin = {
308
337
  },
309
338
 
310
339
  // ── Filters → Sequelize `where` ─────────────────────────────────────────────
311
- // A filter row is { field, op, value, dateModifier? }; op ∈ = != > >= < <= contains in empty.
312
- // Rows with no field or no value are dropped, `in` is comma-split.
340
+ // A filter row is { field, op, value, dateModifier? }; op ∈ = != > >= < <= contains startsWith
341
+ // endsWith in notIn notBetween empty. Rows with no field or no value are dropped, `in` / `notIn`
342
+ // are comma-split, `notBetween` takes [ from, to ] (resolveFilters writes it for the date ops).
313
343
 
314
344
  filtersToWhere(scope, filters) {
315
345
  const { Op } = this.Sequelize
@@ -326,7 +356,13 @@ const AIDashboardMixin = {
326
356
  ? { [Op.or]: [ { [Op.is]: null }, { [Op.eq]: '' } ] }
327
357
  : { [Op.is]: null }
328
358
  else if (filter.op === 'contains') condition = { [Op.like]: `%${filter.value}%` }
329
- else if (filter.op === 'in') condition = { [Op.in]: `${filter.value}`.split(',').map(v => v.trim()).filter(Boolean) }
359
+ else if (filter.op === 'startsWith') condition = { [Op.startsWith]: `${filter.value}` }
360
+ else if (filter.op === 'endsWith') condition = { [Op.endsWith]: `${filter.value}` }
361
+ else if (filter.op === 'in' || filter.op === 'notIn') {
362
+ const list = `${filter.value}`.split(',').map(v => v.trim()).filter(Boolean)
363
+ if (list.length) condition = { [filter.op === 'in' ? Op.in : Op.notIn]: list }
364
+ }
365
+ else if (filter.op === 'notBetween' && Array.isArray(filter.value) && filter.value.length === 2) condition = { [Op.notBetween]: filter.value }
330
366
  else if (ops[filter.op]) condition = { [ops[filter.op]]: filter.value }
331
367
  const target = (filter.dateModifier && bucketExpr(this.conn.fn, field.expr, filter.dateModifier)) || (field.sql ? field.expr : null)
332
368
  if (condition) and.push(target ? this.Sequelize.where(target, condition) : { [field.where]: condition })
@@ -446,7 +482,7 @@ const AIDashboardMixin = {
446
482
  },
447
483
 
448
484
  async nestChart(node, run) {
449
- const { splitBy, splitLimit, splitOther, ...query } = node.query ?? {}
485
+ const { splitBy, splitLimit, splitOther, groupOther, ...query } = node.query ?? {}
450
486
  const plain = { ...node, query: { ...query, dateModifier: '', bucketSize: undefined } }
451
487
  if (![ 'count', 'sum', undefined, '' ].includes(query.aggregation)) return run(plain)
452
488
  const limit = Math.min(20, Math.max(1, parseInt(splitLimit) || 7))
@@ -455,9 +491,9 @@ const AIDashboardMixin = {
455
491
  const rows = ((await run(plain))?.series ?? [])
456
492
  .map(row => ({ label: named(row.label) ? `${row.label}` : '', value: Number(row.value) || 0 }))
457
493
  .sort((a, b) => b.value - a.value)
458
- const top = rows.length > 8 ? rows.slice(0, 7) : rows
494
+ const top = rows.length > 8 ? rows.slice(0, groupOther === false ? 8 : 7) : rows
459
495
  const rest = rows.slice(top.length)
460
- const series = [ ...top, ...(rest.length ? [ { label: 'Other', value: rest.reduce((sum, row) => sum + row.value, 0), other: true } ] : []) ]
496
+ const series = [ ...top, ...(rest.length && groupOther !== false ? [ { label: 'Other', value: rest.reduce((sum, row) => sum + row.value, 0), other: true } ] : []) ]
461
497
 
462
498
  const nested = {}
463
499
  await Promise.all(top.map(async row => {