@zestl/zvolv-widgets 0.0.1 → 0.0.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.
- package/CHANGELOG.md +37 -0
- package/MIRROR_NOTES.md +146 -0
- package/README.md +153 -1
- package/SKILL.md +16 -1
- package/dist/index.cjs +787 -333
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +197 -16
- package/dist/index.d.ts +197 -16
- package/dist/index.js +582 -136
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
Every new feature is opt-in. Configs that existed before this release produce
|
|
6
|
+
byte-identical queries (`test/compat.mjs`). The query-generator changes that
|
|
7
|
+
ZAI-Dashboard and the server must mirror are listed in `MIRROR_NOTES.md`.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **A pivot output name is no longer used as the ES field name.** Pivot rows, columns and summaries now group and aggregate on `masterField`. `title` only names the output key. Before, `groupBy(f, "alias")` queried a field called `alias` and returned no buckets. Configs where `title === masterField` are unchanged.
|
|
11
|
+
- This also fixes the library's own `groupBy(taskField("Status"))`, which used to query `Status.keyword` instead of `status.keyword`.
|
|
12
|
+
- **`fillDateGaps` no longer overwrites rows in the same period.** Day-level rows shown weekly or monthly are now summed per series `valueField`. The first row's other fields are kept. `merge: "last"` restores the old behaviour.
|
|
13
|
+
- **Changing `dateGap` now refetches.** Before, `useWidgetData` kept rows normalised with the previous `dateGap`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Calendar-period grouping.** `groupBy(f, title, { interval, timeZone })` groups by `"day" | "week" | "month" | "quarter" | "year"` using an ES composite `date_histogram` source. Keys are epoch ms at the start of each period.
|
|
17
|
+
- **More `dateGap` units.** `DateGapUnit` adds `"quarter"` and `"year"`. Weeks now start on Monday (ISO), consistent with ES `1w`.
|
|
18
|
+
- **Running totals.** `dateGap.cumulative` (`true` or a list of fields) with a `baseline` value, or with `baselineSource` to have the hook fetch the baseline in one extra request. New pure helper: `applyCumulative`.
|
|
19
|
+
- **Time between two dates.** `dateDiff(start, end | "TODAY", unit)` for `aggregate(..., "avg" | "min" | "max" | "sum" | "percentiles")`. It uses a painless script metric, and documents missing either date are excluded.
|
|
20
|
+
- **Percentiles.** `"percentiles"` is now an `AggregationType`, with `{ percents }`. Rows get `<name>_p50`-style keys (`flattenPercentiles`, `percentileKey`).
|
|
21
|
+
- **Several conditions per metric.** `aggregate(f, type, name, { where: [[field, where(...)], ...] })` filters one metric by AND-ed conditions. All filtered metrics still run in one request. `PivotFilter.combine: "AND"` opts stored configs into AND-ing.
|
|
22
|
+
- **Self-fetching KPI sparklines.** `KpiContentConfig.seriesSource = { mode: "timeseries", dataSource, xField, valueField, dateGap }` makes `KpiWidget` fetch the sparkline itself. A new `externalSeriesByIndex` prop is also accepted.
|
|
23
|
+
- **New exports:**
|
|
24
|
+
- functions: `dateDiff`, `startOfPeriod`, `addPeriod`
|
|
25
|
+
- types: `DateGapOptions`, `DateInterval`, `DateDiffUnit`, `DateDiffSpec`, `DateDiffField`, `AggregateOptions`, `KpiTimeseriesSource`, `FillDateGapsArgs`
|
|
26
|
+
- **New tests:** `test/compat.mjs`, `test/timeseries.mjs`, `test/datediff.mjs`, `test/summaryFilters.mjs`.
|
|
27
|
+
|
|
28
|
+
### Fixed during review
|
|
29
|
+
- **Time zones.** `dateGap` now computes periods in the `groupBy(..., { timeZone })` zone, or in `dateGap.timeZone`. Before, it used the browser's zone, so ES buckets landed in the wrong period whenever the two zones differed. `startOfPeriod` and `addPeriod` take an optional zone, and there is a new `parseDateValue` helper.
|
|
30
|
+
- **Running totals without `end`.** `cumulative` now drops rows before `start` even when `end` is missing. Before, they were counted twice: once in the rows and once in the baseline.
|
|
31
|
+
- **Sort order.** AND-filtered metrics keep the summary `bucket_sort`.
|
|
32
|
+
- **KPI sparklines.** A self-fetched sparkline is cleared when its item loses `seriesSource.dataSource` or the items are reordered.
|
|
33
|
+
- **`dateDiff` end values.** `end` now accepts any date token and literal dates (`"2026-03-31"`, ISO date-times). An unknown `{@[…]}` token logs a warning instead of silently being queried as a field.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- **Week periods in `fillDateGaps` start on Monday** instead of following the dayjs locale, which defaulted to Sunday.
|
|
37
|
+
- **Version bumped to 0.0.2.** `package.json` said `0.0.0`.
|
package/MIRROR_NOTES.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Mirror notes: what ZAI-Dashboard and the server must copy
|
|
2
|
+
|
|
3
|
+
`src/runtime/queryGenerator.ts` is a verbatim port of ZAI-Dashboard
|
|
4
|
+
`src/utils/queryGenerator.ts`. Every change in this repo is in a block marked
|
|
5
|
+
`// [zvolv-widgets extension]`. Search for that marker to find each one.
|
|
6
|
+
|
|
7
|
+
**Compatibility guarantee.** For every config shape that existed before 0.0.2,
|
|
8
|
+
the generated query is byte-identical. `test/compat.mjs` checks 12 such shapes
|
|
9
|
+
against snapshots captured from the old code.
|
|
10
|
+
|
|
11
|
+
**The one intended exception (item 1).** A pivot entry whose `title` differs
|
|
12
|
+
from its `masterField` now queries `masterField`. Before, it queried a field
|
|
13
|
+
named after the title, which normally does not exist. Saved ZAI-Dashboard
|
|
14
|
+
configs observed so far have `title === masterField`. Before rolling out,
|
|
15
|
+
check task widgets that might have stored `title: "Status"` with
|
|
16
|
+
`masterField: "status"`.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## A. ZAI-Dashboard `queryGenerator.ts`: changes to copy
|
|
21
|
+
|
|
22
|
+
### A1. ES field comes from `masterField`, not `title` (bug fix)
|
|
23
|
+
|
|
24
|
+
In each function below, `title` stays the aggregation or source **name**, which
|
|
25
|
+
is the output key. Only the **field** changes.
|
|
26
|
+
|
|
27
|
+
| Function | Before | After |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `getCompositeAggregations` | `terms.field` built from `row.title` in all four branches: `resolveFormSearchViewField(row.title)`, `` `${row.title}.mediaKey.keyword` ``, `` `${row.title}.keyword` ``, `row.title` | the same, built from `esField = row.masterField \|\| row.title` |
|
|
30
|
+
| `getCompositeAggregations` | `terms: scriptFields[row.title]` | unchanged (script fields are keyed by header) |
|
|
31
|
+
| `getColumnAggregations` | `terms: { field: col.title }` | `terms: { field: col.masterField \|\| col.title }` |
|
|
32
|
+
| `getSummaryAggregations` | `aggField = sumary.title` (and `.keyword`, `.mediaKey.keyword`, `resolveFormSearchViewField(...)` variants) | the same, built from `esField = sumary.masterField \|\| sumary.title` |
|
|
33
|
+
| `getSummaryAggregations` | `usesRawField = aggField === sumary.title`, `addBucketSort(sumary.title)` | `usesRawField = aggField === esField`, `addBucketSort(esField)` |
|
|
34
|
+
|
|
35
|
+
### A2. `date_histogram` grouping (`PivotRow.interval`)
|
|
36
|
+
|
|
37
|
+
In `getCompositeAggregations`, before the existing `FORM_SEARCH` / `FILE_UPLOAD_V2` /
|
|
38
|
+
`.keyword` / raw branches:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
if (row.interval && (dateTypes.includes(mfType) || isTaskDateField)) {
|
|
42
|
+
sourceObj[row.title] = { date_histogram: {
|
|
43
|
+
field: esField,
|
|
44
|
+
calendar_interval: { day: '1d', week: '1w', month: '1M', quarter: '1q', year: '1y' }[row.interval],
|
|
45
|
+
time_zone: row.timeZone || timezone, // module-level Intl zone
|
|
46
|
+
...(sortOrder ? { order: sortOrder } : {}),
|
|
47
|
+
} };
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- A task date field is one in `taskDate`, where the row or its field metadata has `isTaskField` or `taskfield` set.
|
|
52
|
+
- When `interval` is set on a non-date field, the generator calls `console.warn` and falls back to the existing branches.
|
|
53
|
+
- Helpers: `getDateHistogramSource`, `HISTOGRAM_INTERVALS`.
|
|
54
|
+
|
|
55
|
+
### A3. Per-summary extensions: `dateDiff`, `percentiles`, AND-ed filters
|
|
56
|
+
|
|
57
|
+
**New module state.** `summaryFilterLists: Map<string, any[]>`, which is
|
|
58
|
+
`pivot.filters` grouped by `field` with every entry kept. The original
|
|
59
|
+
`filterMap` keeps only the last entry per field.
|
|
60
|
+
- `generateQuery` resets it next to `currentMappings`.
|
|
61
|
+
- `generateQuery` fills it with `groupPivotFilters(dataSource.pivot.filters)` right after building `filterMap2`.
|
|
62
|
+
|
|
63
|
+
**Hook.** At the top of the `pivotBody.summary.forEach` loop in
|
|
64
|
+
`getSummaryAggregations`, after `aggDataPoints.summary.push`, add:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
if (buildExtendedSummary(sumary, summaryAggs, loggedInUser)) return;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`buildExtendedSummary` returns `false` and changes nothing unless one of these holds:
|
|
71
|
+
|
|
72
|
+
1. **`sumary.dateDiff = { start, end, unit }` is set.** The metric becomes `{ [type]: { script } }`, where `script` is:
|
|
73
|
+
- with two date fields: `{ source: "if(doc['S'].size()==0 || doc['E'].size()==0) { return null; } return (doc['E'].value.millis - doc['S'].value.millis) / <div>;", lang: 'painless' }`
|
|
74
|
+
- with a fixed `end`: `{ source: "if(doc['S'].size()==0) { return null; } return (params.now - doc['S'].value.millis) / <div>;", lang: 'painless', params: { now } }`. `end` is fixed when it is either:
|
|
75
|
+
- a `dateFormulas` token: `now = dayjs(getDate(end)).valueOf()`
|
|
76
|
+
- a literal date matching `/^\d{4}-\d{2}-\d{2}([T ][\d:.]+(Z|±hh:mm)?)?$/`: `now = dayjs(end).valueOf()`, where a date-only value means local midnight
|
|
77
|
+
- Any other `end` is a field name. An unknown `{@[…]}` token logs a `console.warn`.
|
|
78
|
+
- `<div>` is `86400000.0` for days, `3600000.0` for hours, `60000.0` for minutes.
|
|
79
|
+
- It is always wrapped in a filter: `must` gets `{ exists: { field: S } }`, plus `{ exists: { field: E } }` unless `end` is fixed.
|
|
80
|
+
2. **`sumary.type === 'percentiles'` and `sumary.percents` is set.** The metric gets `percents` (`{ percentiles: { field | script, percents } }`).
|
|
81
|
+
3. **The summary has two or more `pivot.filters` entries, at least one with `combine: 'AND'`.** Every entry is AND-ed:
|
|
82
|
+
- Each entry is converted with the original `getFilters(fb, 'PIVOT')` call and the same `fb` shape as the original branch.
|
|
83
|
+
- Operators in `mustNotOp` go into `bool.must_not`; all others go into `bool.must`.
|
|
84
|
+
- `_ALL` and `MULTIPLE` are skipped, because they only make sense as global filters.
|
|
85
|
+
|
|
86
|
+
The output nesting is the same as the original per-summary filter branch:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
"<name>": { "filter": { "bool": { "must": [...], "must_not": [...] } }, "aggs": { "<name>": { "<type>": { ... } } } }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
or the bare `"<name>": { "<type>": { ... } }` when there are no conditions. A
|
|
93
|
+
summary-only query with a filtered summary therefore still takes the existing
|
|
94
|
+
`dummy_row` composite path. Field resolution for plain-field percentiles and
|
|
95
|
+
AND-filtered metrics (`.keyword`, `FORM_SEARCH` `.view`, `FILE_UPLOAD_V2`
|
|
96
|
+
`.mediaKey.keyword`, text → `value_count`) copies the original loop.
|
|
97
|
+
|
|
98
|
+
**Sort.** AND-filtered single-value metrics on a raw field get the same
|
|
99
|
+
`<name>_sort` `bucket_sort` as the original `addBucketSort`, so
|
|
100
|
+
`buildExtendedSummary` receives `sortKeys`. Percentiles and `dateDiff` metrics
|
|
101
|
+
get no sort, because they have no single-value field to sort on.
|
|
102
|
+
|
|
103
|
+
Helpers: `buildExtendedSummary`, `finish`, `getDateDiffScript`, `getDateDiffFixedEnd`,
|
|
104
|
+
`getSummaryFilterClause`, `groupPivotFilters`, `DATE_DIFF_DIVISOR`.
|
|
105
|
+
|
|
106
|
+
### A4. Optional: `fillDateGaps`, if the host has its own copy
|
|
107
|
+
|
|
108
|
+
If the host has its own `fillDateGaps`, mirror these changes from `src/transforms/dateGaps.ts`:
|
|
109
|
+
- Rows in the same period are summed, not overwritten.
|
|
110
|
+
- Weeks start on Monday (ISO), which matches ES `1w`.
|
|
111
|
+
- `quarter` and `year` units are supported.
|
|
112
|
+
- Periods are keyed by their start instant.
|
|
113
|
+
- An optional `timeZone` computes periods on that zone's calendar. It must match the `date_histogram` `time_zone`; otherwise bucket keys land in the previous or next period.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## B. Server: aggregation response flattener
|
|
118
|
+
|
|
119
|
+
This repo cannot see the flattener that turns ES `aggregations` into
|
|
120
|
+
`elements` rows, driven by `aggsDataPoints.rows` / `cols` / `summary`. Each
|
|
121
|
+
item below is what the client expects. Verify each one against a live index.
|
|
122
|
+
|
|
123
|
+
| # | ES response shape | What the client expects in each row | Likely server change |
|
|
124
|
+
| --- | --- | --- | --- |
|
|
125
|
+
| B1 | `date_histogram` composite source: `bucket.key = { "Month": 1767205800000 }` (epoch ms, a number) | `row["Month"] = 1767205800000` | **Probably none.** Composite `terms` keys on date fields are already epoch-ms numbers. Confirm the flattener copies `bucket.key[row]` without assuming the source is `terms`. |
|
|
126
|
+
| B2 | Filter-wrapped script metric: `bucket["Avg Days"] = { doc_count, "Avg Days": { value: 4.5 } }` | `row["Avg Days"] = 4.5` | **None** if filtered summaries already work: the nesting is identical. A bucket where no document has both dates returns `value: null`. Keep that as `null`; the client coerces it to 0 for charts. |
|
|
127
|
+
| B3 | `percentiles`: `{ values: { "50.0": 3, "95.0": 9 } }`, either at the top level (summary only) or under the filter wrapper | Either `row["Days"] = { values: {...} }` or `{ "50.0": 3, ... }`, or flat `row["Days_p50"]` / `row["Days_p95"]` | **Likely needed.** If the flattener reads only `.value`, percentiles come back `undefined`. Pass the `values` object through, or emit `<name>_p<percent>` keys (`99.9` → `_p99_9`). The client accepts all three shapes. |
|
|
128
|
+
| B4 | Summary-only `dateDiff` metric under `dummy_row` | `row["Avg Days to Offer"]` | None. Same path as existing filtered KPI summaries. |
|
|
129
|
+
| B5 | Script metrics in general | n/a | `doc[...].value.millis` is the idiom `getFormula`'s DAYS_DIFF already uses, so it works on the current cluster. On ES 8+ it would need `.toInstant().toEpochMilli()`. |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## C. Not query-generator changes, for reference
|
|
134
|
+
|
|
135
|
+
- **`useWidgetData`:**
|
|
136
|
+
- New `dateGap.merge`, `cumulative`, `baseline` and `baselineSource` options. `baselineSource` makes one extra request with `dateField < start` and no dashboard date range.
|
|
137
|
+
- Percentile-row flattening.
|
|
138
|
+
- `dateGap` content changes now trigger a refetch. Before, the effect ignored them and showed stale rows.
|
|
139
|
+
- **`KpiWidget`:**
|
|
140
|
+
- Self-fetches `seriesSource: { mode: "timeseries", dataSource, ... }`, with one request per content item.
|
|
141
|
+
- New `externalSeriesByIndex` prop.
|
|
142
|
+
- **`dataSource.ts`:**
|
|
143
|
+
- `groupBy(f, title, { interval, timeZone })`.
|
|
144
|
+
- `dateDiff()`.
|
|
145
|
+
- `aggregate(f, type, name, { percents, where })`.
|
|
146
|
+
- `pivot()`, `formSource()` and `taskSource()` move `where` conditions into `pivot.filters` with `combine: "AND"`.
|
package/README.md
CHANGED
|
@@ -343,7 +343,7 @@ queried. The query generator reads `dataSource`, so a widget with a perfect
|
|
|
343
343
|
config and no data source shows "No data source configured".
|
|
344
344
|
|
|
345
345
|
```tsx
|
|
346
|
-
import { formSource, taskSource, field, taskField, groupBy, aggregate, derive, where, pivot } from "@zestl/zvolv-widgets";
|
|
346
|
+
import { formSource, taskSource, field, taskField, groupBy, aggregate, dateDiff, derive, where, pivot } from "@zestl/zvolv-widgets";
|
|
347
347
|
|
|
348
348
|
const region = field("Region", "DROP_DOWN");
|
|
349
349
|
const revenue = field("Revenue", "INPUT_CURRENCY");
|
|
@@ -394,6 +394,157 @@ field("Created", "INPUT_DATE", { filter: where("DATE_IS", "{@[TODAY]}") })
|
|
|
394
394
|
defaultWidgetFilters={[{ field: "Status", operator: "IN", value: ["Open"] }]}
|
|
395
395
|
```
|
|
396
396
|
|
|
397
|
+
### Output names vs field names
|
|
398
|
+
|
|
399
|
+
`groupBy(f, title)` and `aggregate(f, type, outputName)` take an output name,
|
|
400
|
+
which is the key the rows come back under. The query always groups and
|
|
401
|
+
aggregates on the field's `masterfield`. So `groupBy(requestDate, "day")`
|
|
402
|
+
queries `Request Date` and returns rows keyed `day`. (Before 0.0.2, the title
|
|
403
|
+
was used as the field name, so an alias returned no buckets.)
|
|
404
|
+
|
|
405
|
+
### Grouping by calendar period
|
|
406
|
+
|
|
407
|
+
Pass `interval` to group a date field by calendar period
|
|
408
|
+
(`date_histogram` in Elasticsearch) instead of by exact value:
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
const requestDate = field("Request Date", "DATE_PICKER");
|
|
412
|
+
const requests = field("Request ID", "INPUT_TEXT");
|
|
413
|
+
|
|
414
|
+
const monthly = formSource(1042, [requestDate, requests], {
|
|
415
|
+
pivot: pivot({
|
|
416
|
+
rows: [groupBy(requestDate, "Month", { interval: "month" })], // day | week | month | quarter | year
|
|
417
|
+
summary: [aggregate(requests, "value_count", "Requests")],
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
<LineChartWidget
|
|
422
|
+
dataSource={monthly}
|
|
423
|
+
configurations={{ series: [{ argumentField: "Month", valueField: "Requests" }] }}
|
|
424
|
+
dateGap={{ unit: "month", start: "2026-01-01", end: "2026-12-31" }}
|
|
425
|
+
/>
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
- Row keys are epoch ms at the start of each period.
|
|
429
|
+
- Weeks start on Monday (ISO).
|
|
430
|
+
- Period boundaries use the browser's time zone. Pass `{ interval, timeZone: "Asia/Kolkata" }` to fix one. `dateGap` then fills and merges periods in that same zone.
|
|
431
|
+
- `interval` on a non-date field is ignored, with a console warning.
|
|
432
|
+
|
|
433
|
+
`dateGap` fills empty periods with zero rows. Rows that fall in the same
|
|
434
|
+
period are **summed** (`merge: "sum"`, the default), so day-level data shown
|
|
435
|
+
weekly or monthly no longer loses rows. `merge: "last"` restores the old
|
|
436
|
+
keep-the-last-row behaviour.
|
|
437
|
+
|
|
438
|
+
### Running totals (level over time)
|
|
439
|
+
|
|
440
|
+
```tsx
|
|
441
|
+
<LineChartWidget
|
|
442
|
+
dataSource={monthly}
|
|
443
|
+
configurations={{ series: [{ argumentField: "Month", valueField: "Requests" }] }}
|
|
444
|
+
dateGap={{
|
|
445
|
+
unit: "month", start: "2026-01-01", end: "2026-12-31",
|
|
446
|
+
cumulative: true, // or ["Requests"]
|
|
447
|
+
baseline: { Requests: 1250 }, // the total before `start`
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
`cumulative` runs after gap filling, so each point is the level at the end of
|
|
453
|
+
its period. Rows before `start` are dropped even when there is no `end`,
|
|
454
|
+
because the baseline already counts them. You can let the hook fetch the baseline instead. This costs one
|
|
455
|
+
extra request:
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
dateGap={{
|
|
459
|
+
unit: "month", start: "2026-01-01", end: "2026-12-31", cumulative: true,
|
|
460
|
+
baselineSource: {
|
|
461
|
+
dataSource: formSource(1042, [requestDate, requests], {
|
|
462
|
+
pivot: pivot({ summary: [aggregate(requests, "value_count", "Requests")] }), // same output name
|
|
463
|
+
}),
|
|
464
|
+
dateField: "Request Date", // the hook adds Request Date < 2026-01-01
|
|
465
|
+
},
|
|
466
|
+
}}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The baseline query applies the widget's dashboard and widget filters, but not
|
|
470
|
+
the dashboard date range, because that range would exclude everything before
|
|
471
|
+
`start`. An explicit `baseline` wins over the fetched value, field by field.
|
|
472
|
+
|
|
473
|
+
### KPI sparklines without custom code
|
|
474
|
+
|
|
475
|
+
A KPI item can fetch its own sparkline:
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
<KpiWidget
|
|
479
|
+
dataSource={formSource(1042, [requests], { pivot: pivot({ summary: [aggregate(requests, "value_count", "Total")] }) })}
|
|
480
|
+
configurations={{ contents: [{
|
|
481
|
+
title: "Requests", dataPoint: "Total", template: "trend",
|
|
482
|
+
seriesSource: {
|
|
483
|
+
mode: "timeseries",
|
|
484
|
+
dataSource: monthly, // grouped by a date row with `interval`
|
|
485
|
+
xField: "Month", valueField: "Requests",
|
|
486
|
+
dateGap: { unit: "month", start: "2026-01-01", end: "2026-06-30", cumulative: true },
|
|
487
|
+
},
|
|
488
|
+
}] }}
|
|
489
|
+
/>
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Each such item makes one request through `useWidgetData`, with the widget's
|
|
493
|
+
dashboard filters applied. `externalSeriesByIndex={{ 0: [..] }}` still works
|
|
494
|
+
and takes precedence.
|
|
495
|
+
|
|
496
|
+
### Time between two dates (avg / min / max / sum / percentiles)
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
const offerDate = field("Actual Offer Date", "DATE_PICKER");
|
|
500
|
+
const partner = field("Lead Partner", "DROP_DOWN");
|
|
501
|
+
|
|
502
|
+
// Overall: one number
|
|
503
|
+
formSource(1042, [requestDate, offerDate], { pivot: pivot({
|
|
504
|
+
summary: [aggregate(dateDiff(requestDate, offerDate, "days"), "avg", "Avg Days to Offer")],
|
|
505
|
+
}) });
|
|
506
|
+
|
|
507
|
+
// Per partner, or per month with groupBy(requestDate, "Month", { interval: "month" })
|
|
508
|
+
formSource(1042, [requestDate, offerDate, partner], { pivot: pivot({
|
|
509
|
+
rows: [groupBy(partner)],
|
|
510
|
+
summary: [
|
|
511
|
+
aggregate(dateDiff(requestDate, offerDate), "avg", "Avg Days to Offer"),
|
|
512
|
+
aggregate(dateDiff(requestDate, offerDate), "percentiles", "Days", { percents: [50, 90] }),
|
|
513
|
+
aggregate(dateDiff(requestDate, "TODAY"), "max", "Oldest Open (days)"),
|
|
514
|
+
],
|
|
515
|
+
}) });
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- The value is `end − start`. The unit is `"days"`, `"hours"` or `"minutes"`, and the result is fractional.
|
|
519
|
+
- Documents missing either date are skipped.
|
|
520
|
+
- `end` can be a fixed end instead of a field: `"TODAY"`, a date token such as `{@[30DAYSAGO]}`, or a literal date (`"2026-03-31"` means local midnight; an ISO date-time is also accepted). Only the start date is then required. Any other string is treated as a field name, and an unknown `{@[…]}` token logs a warning.
|
|
521
|
+
- Percentiles come back as `<name>_p50`, `<name>_p90` (`99.9` becomes `_p99_9`). `<name>` itself holds the median when 50 was requested.
|
|
522
|
+
|
|
523
|
+
### Filtered metrics: many KPIs, one request
|
|
524
|
+
|
|
525
|
+
```tsx
|
|
526
|
+
const amount = field("Amount", "INPUT_CURRENCY");
|
|
527
|
+
const status = field("Status", "DROP_DOWN");
|
|
528
|
+
const invoiceDate = field("Invoice Date", "DATE_PICKER");
|
|
529
|
+
|
|
530
|
+
formSource(1042, [amount, status, invoiceDate], { pivot: pivot({
|
|
531
|
+
summary: [
|
|
532
|
+
aggregate(amount, "sum", "Billed", { where: [
|
|
533
|
+
[status, where("IN", "Invoiced", "Paid")],
|
|
534
|
+
[invoiceDate, where("BETWEEN", "2026-04-01", "2026-06-30")], // AND-ed
|
|
535
|
+
] }),
|
|
536
|
+
aggregate(amount, "sum", "Pipeline", { where: [[status, where("IN", "Quoted")]] }),
|
|
537
|
+
aggregate(amount, "sum", "Total"),
|
|
538
|
+
],
|
|
539
|
+
}) });
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Each metric is filtered on its own, and all of them run in **one** request. A
|
|
543
|
+
KPI card with 20 numbers on one form is one call. `pivot()`, `formSource()` and
|
|
544
|
+
`taskSource()` move the `where` conditions into `pivot.filters`, marked
|
|
545
|
+
`combine: "AND"`. Stored `pivot.filters` entries without that marker keep the
|
|
546
|
+
original rule: the last entry per metric wins.
|
|
547
|
+
|
|
397
548
|
---
|
|
398
549
|
|
|
399
550
|
## 5. Controlling the UI
|
|
@@ -650,6 +801,7 @@ writes widget code. Without it, Claude tends to write its own data fetching.
|
|
|
650
801
|
- **Tables fetch up to `rowLimit` rows (default 1000).** There is no server pagination yet. Server-side search, filters and sort run over the full dataset, but only the first `rowLimit` matches are shown.
|
|
651
802
|
- **Column-filter options** come from the rows loaded so far. They accumulate across fetches, so options do not vanish once one is ticked. They are not a full distinct-values query.
|
|
652
803
|
- **Widget filters are AND-ed.** For OR logic, pass a host ZFilters payload through `localFilters`.
|
|
804
|
+
- **0.0.2 query features need server and host support.** `interval` grouping, `dateDiff` metrics, percentiles and AND-ed per-metric filters produce ES shapes that the server's aggregation flattener and ZAI-Dashboard must also handle. See `MIRROR_NOTES.md`.
|
|
653
805
|
- **Calendar / MeetingScheduler are simplified.** There is no drag-to-resize, month-grid layout or virtual scrolling. For full parity, wrap FullCalendar in `render`.
|
|
654
806
|
|
|
655
807
|
---
|
package/SKILL.md
CHANGED
|
@@ -127,7 +127,7 @@ Stored table columns are `{ label, dataPoint, type }` and are read as-is.
|
|
|
127
127
|
## Authoring a data source
|
|
128
128
|
|
|
129
129
|
```tsx
|
|
130
|
-
import { formSource, taskSource, field, taskField, groupBy, aggregate, derive, where, pivot } from "@zestl/zvolv-widgets";
|
|
130
|
+
import { formSource, taskSource, field, taskField, groupBy, aggregate, dateDiff, derive, where, pivot } from "@zestl/zvolv-widgets";
|
|
131
131
|
|
|
132
132
|
const region = field("Region", "DROP_DOWN"); // use the REAL element type
|
|
133
133
|
const revenue = field("Revenue", "INPUT_CURRENCY");
|
|
@@ -147,6 +147,21 @@ field("Status", "DROP_DOWN", { filter: where("IN", "Open") }); // baked-in auth
|
|
|
147
147
|
|
|
148
148
|
Element type → ES type: `INPUT_NUMBER`/`INPUT_CURRENCY`/`INPUT_PERCENT` → FLOAT · `FORM_SEARCH`/`INPUT_TAG` → ARRAY · `CHECK_BOX` → BOOLEAN · else STRING.
|
|
149
149
|
|
|
150
|
+
Time series, date differences and filtered metrics (0.0.2). Never post-process rows for these:
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
groupBy(requestDate, "Month", { interval: "month" }) // date_histogram; day|week(Mon)|month|quarter|year; keys = epoch ms
|
|
154
|
+
aggregate(dateDiff(requestDate, offerDate, "days"), "avg", "Avg Days") // end − start; docs missing a date skipped
|
|
155
|
+
aggregate(dateDiff(requestDate, "TODAY"), "max", "Age")
|
|
156
|
+
aggregate(dateDiff(a, b), "percentiles", "Days", { percents: [50, 90] }) // rows get Days_p50, Days_p90
|
|
157
|
+
aggregate(amount, "sum", "Billed", { where: [[status, where("IN", "Paid")], [date, where("BETWEEN", from, to)]] }) // AND-ed; many metrics = 1 request
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `title` / `outputName` only name the output key. The query uses the field's `masterfield`.
|
|
161
|
+
- `dateGap={{ unit, start, end }}` sums rows in the same period (`merge: "last"` gives the old behaviour).
|
|
162
|
+
- Add `cumulative: true | [fields]` with `baseline: { field: n }`, or `baselineSource: { dataSource, dateField }`, for running totals.
|
|
163
|
+
- KPI sparkline: `contents[i].seriesSource = { mode: "timeseries", dataSource, xField, valueField, dateGap }`. The widget fetches it.
|
|
164
|
+
|
|
150
165
|
## Writing configuration
|
|
151
166
|
|
|
152
167
|
```tsx
|