@zestl/zvolv-widgets 0.0.1 → 0.0.3

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 ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ ## 0.0.3
4
+
5
+ These are all client-side enhancements that run after the query; none changes
6
+ what is queried. Existing configs give byte-identical queries, rows and default
7
+ markup, which `test/compat.mjs` checks against snapshots captured from 0.0.2.
8
+ There is nothing for ZAI-Dashboard or the server to mirror.
9
+
10
+ ### Added
11
+ - **KPI card layout.**
12
+ - `config.layout: "card"` (or `cardConfig.layout`) renders every item inside one card, through a `"KPI_CARD:card"` template override or the default `KpiCard`.
13
+ - `KpiWidget` takes `renderCard` (or `render`), which receives `{ vms, byKey, roles, state, tokens, config, onItemClick }`. Both are used only in card mode, so a generic `render` a host passes to every widget leaves KPIs unchanged.
14
+ - `KpiContentConfig.role` (`"main" | "sub" | "chip" | "series"`) places an item in the card; `resolveKpiRoles` does this sorting.
15
+ - `KpiSparkline` gains `width`, `height`, `area` and `scale: "minmax" | "zero"`.
16
+ - **`unpivot`.** On `useWidgetData` and every data widget. It turns row 0's columns into one `{ label, value }` row per column, in the given order. A missing column gives 0. `extra` copies more columns onto the output rows. It runs after postOperations. With no declared series, x and y become `labelField` and `valueField`. Pure helper: `unpivotRows`.
17
+ - **`ranking.order: "none"`.** `RankingBarWidget` keeps the incoming row order.
18
+ - **`FunnelWidget` (type `FUNNEL`).** Each stage resolves to `{ label, value, ratioToFirst, stepPct, isFirst, index, raw }`, with `onItemClick(row)` for drill-down. `render` gets `{ rows, first, tokens, config, toLabel, onItemClick }`. Pure helper: `computeFunnelRows`.
19
+ - **`categoryGap`.** On `useWidgetData` and every data widget. It gives one row per category in the given order, or in the form's dropdown order with `"fromForm"`. Missing buckets get 0 for every series valueField, and values that are not listed are kept at the end. It runs after postOperations and widget filters, and before local search, column filters and sort. Pure helpers: `fillCategoryGaps`, `elementOptionValues`.
20
+ - **`FilterPanelWidget` options from a data source.**
21
+ - `filters[].optionsSource = { dataSource, labelField, valueField, sort?, allLabel? }` fetches the options in one request: a distinct-values aggregation over the whole index, with the source's filters applied server-side (`optionsDataSource`).
22
+ - Values restored from URL params match numeric options (`matchFilterOption`).
23
+ - `onFiltersChange(WidgetFilter[])` emits `{ field, operator: "IN", value: [v] }`, or nothing for "All".
24
+ - The `renderFilter` slot receives `{ control, options, value, setValue, loading, tokens }`.
25
+ - Pure helpers: `filterPanelToWidgetFilters`, `rowsToFilterOptions`.
26
+ - **New tests:** `kpiCard`, `unpivot`, `funnel`, `categoryGap`, `filterOptions`. `compat` now also snapshots rows and default markup. A missing snapshot now fails the test instead of being regenerated.
27
+
28
+ ## 0.0.2
29
+
30
+ Every new feature is opt-in. Configs that existed before this release produce
31
+ byte-identical queries (`test/compat.mjs`). The query-generator changes that
32
+ ZAI-Dashboard and the server must mirror are listed in `MIRROR_NOTES.md`.
33
+
34
+ ### Fixed
35
+ - **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.
36
+ - This also fixes the library's own `groupBy(taskField("Status"))`, which used to query `Status.keyword` instead of `status.keyword`.
37
+ - **`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.
38
+ - **Changing `dateGap` now refetches.** Before, `useWidgetData` kept rows normalised with the previous `dateGap`.
39
+
40
+ ### Added
41
+ - **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.
42
+ - **More `dateGap` units.** `DateGapUnit` adds `"quarter"` and `"year"`. Weeks now start on Monday (ISO), consistent with ES `1w`.
43
+ - **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`.
44
+ - **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.
45
+ - **Percentiles.** `"percentiles"` is now an `AggregationType`, with `{ percents }`. Rows get `<name>_p50`-style keys (`flattenPercentiles`, `percentileKey`).
46
+ - **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.
47
+ - **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.
48
+ - **New exports:**
49
+ - functions: `dateDiff`, `startOfPeriod`, `addPeriod`
50
+ - types: `DateGapOptions`, `DateInterval`, `DateDiffUnit`, `DateDiffSpec`, `DateDiffField`, `AggregateOptions`, `KpiTimeseriesSource`, `FillDateGapsArgs`
51
+ - **New tests:** `test/compat.mjs`, `test/timeseries.mjs`, `test/datediff.mjs`, `test/summaryFilters.mjs`.
52
+
53
+ ### Fixed during review
54
+ - **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.
55
+ - **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.
56
+ - **Sort order.** AND-filtered metrics keep the summary `bucket_sort`.
57
+ - **KPI sparklines.** A self-fetched sparkline is cleared when its item loses `seriesSource.dataSource` or the items are reordered.
58
+ - **`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.
59
+
60
+ ### Changed
61
+ - **Week periods in `fillDateGaps` start on Monday** instead of following the dayjs locale, which defaulted to Sunday.
62
+ - **Version bumped to 0.0.2.** `package.json` said `0.0.0`.
@@ -0,0 +1,156 @@
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"`.
147
+
148
+ ---
149
+
150
+ ## 0.0.3: nothing to mirror
151
+
152
+ `unpivot`, `categoryGap`, the KPI card layout, `FunnelWidget` and filter
153
+ `optionsSource` all run in the browser after the query. They add no query
154
+ shapes and need no server change. `categoryGap: "fromForm"` reads the form
155
+ through the same `getSingleForm` call the widget's access check already makes.
156
+
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,268 @@ 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
+
548
+ ### Reshaping rows after the query (0.0.3)
549
+
550
+ These run in the browser after the query returns. They never change what is
551
+ queried.
552
+
553
+ **One wide row → one row per category (`unpivot`).** A multi-number source
554
+ returns one row such as `{ Longlist: 4, Contacted: 4, … }`. Turn it into one
555
+ row per column, in your order:
556
+
557
+ ```tsx
558
+ const STAGES = ["Longlist", "Contacted", "Interviewed", "Offered", "Placed"];
559
+
560
+ <RankingBarWidget
561
+ dataSource={FUNNEL_ONE}
562
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }}
563
+ configurations={{ ranking: { order: "none" } }} // keep stage order; ratio = value / max
564
+ />
565
+ ```
566
+
567
+ - Output rows are `{ label, value }`. Rename the keys with `labelField` / `valueField`.
568
+ - A missing column gives 0.
569
+ - `unpivot` runs after `postOperations`, so a `derive()` column can be a stage.
570
+ - With no declared `series`, x and y become `labelField` and `valueField`.
571
+ - `extra` copies a source column onto the stage row whose name it starts with. `"Contacted %"` lands on the "Contacted" row as `step`. A source column that matches no stage is copied onto every row.
572
+
573
+ **All categories, in dropdown order (`categoryGap`).** ES returns a bucket only
574
+ for values that have data. `categoryGap` adds a zero row for every missing
575
+ category and orders the rows:
576
+
577
+ ```tsx
578
+ <BarChartWidget
579
+ dataSource={MINI_FUNNEL}
580
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // or an explicit list
581
+ widgetFilters={[{ field: "Assignment", operator: "IN", value: [title] }]}
582
+ render={({ rows }) => <MiniBars rows={rows} />}
583
+ />
584
+ ```
585
+
586
+ - `field` is the chart's argumentField.
587
+ - `"fromForm"` reads that field's dropdown options from the form schema, once per form; it reuses the widget's form lookup.
588
+ - Every series `valueField` is 0 in the added rows.
589
+ - Values that are not in the list are kept, after the listed ones.
590
+ - It runs after `postOperations` and widget filters, and before local search, column filters and sort.
591
+
592
+ ### Funnel
593
+
594
+ ```tsx
595
+ <FunnelWidget
596
+ dataSource={FUNNEL_ONE}
597
+ unpivot={{ columns: STAGES }}
598
+ onItemClick={(row) => setStage(row.label)}
599
+ />
600
+ ```
601
+
602
+ Each stage resolves to `{ label, value, ratioToFirst, stepPct, isFirst, index, raw }`:
603
+ - `stepPct` is value ÷ previous stage × 100. It is `null` for the first stage, and after a stage of 0.
604
+ - `ratioToFirst` is value ÷ first stage. It is 0 when the first stage is 0.
605
+
606
+ Stages are never re-sorted. `render` gets `{ rows, first, tokens, config, toLabel, onItemClick }`.
607
+
608
+ ### One KPI card from several numbers
609
+
610
+ ```tsx
611
+ <KpiWidget dataSource={cardSource} configurations={{ layout: "card", contents: [
612
+ { dataPoint: "value", role: "main", title: "Open mandates", template: "chart", seriesPoints: P1toP12 },
613
+ { dataPoint: "total", role: "sub", title: "total" },
614
+ { dataPoint: "atRisk", role: "sub", title: "at risk" },
615
+ { dataPoint: "P12", role: "chip", template: "trend", deltaSource: "datapoint", deltaPoint: "P8",
616
+ deltaMode: "absolute", subtitle: "vs 4 wk" },
617
+ ]}} />
618
+ ```
619
+
620
+ The default card puts the items in slots by `role`:
621
+ - `main` is the big value. Without a `main` role, it is the first item with no role.
622
+ - `sub` items are small inline values under it.
623
+ - `chip` is the change badge (its `delta`).
624
+ - `series` supplies the sparkline. Without it, the main item's series is used.
625
+
626
+ Card mode turns on only with `layout: "card"`, set either at the config root
627
+ or in `cardConfig`, where platform KPI payloads keep their items. To build the
628
+ card yourself, pass `renderCard={({ vms, byKey, roles, state, tokens }) => …}`;
629
+ `render` also works, but likewise only in card mode, so a `render` a host
630
+ passes to every widget never changes a KPI. `byKey` maps each `dataPoint` to
631
+ its resolved item; the first item wins when two share one. You can also
632
+ register a `"KPI_CARD:card"` template. `KpiSparkline` accepts
633
+ `width`, `height`, `area` (fill under the line) and `scale: "minmax" | "zero"`.
634
+
635
+ ### Filter options from another form
636
+
637
+ ```tsx
638
+ <FilterPanelWidget
639
+ configurations={{ filters: [{
640
+ key: "scope", field: "Assignment", type: "select",
641
+ optionsSource: {
642
+ dataSource: OPEN_MANDATES, // e.g. a formSource on Assignment with Status IN Open
643
+ labelField: "Assignment Title", valueField: "Assignment Title",
644
+ sort: "asc", allLabel: "Firm-wide",
645
+ },
646
+ }] }}
647
+ onFiltersChange={(filters) => setScopeFilters(filters)} // → other widgets' widgetFilters
648
+ renderFilter={({ options, value, setValue, loading }) => <MySelect … />}
649
+ />
650
+ ```
651
+
652
+ - **Fetch:** the options come from one request: a distinct-values (composite terms) aggregation over the **whole index**, with the source's own filters applied by Elasticsearch. It is not built from a page of rows. The cap is the composite size (50,000 distinct values, or `displayRowLimit`). Declare `labelField` and `valueField` in the source's `fields` so text fields aggregate on `.keyword`. A source that already has a `pivot` is used as is. The panel refetches only when `optionsSource.dataSource` changes.
653
+ - **Where the filter runs:** the emitted filter is applied in the target widget's ES query, so over the whole index. Only automation-row and legacy widget-id sources, which have no query, filter their loaded rows.
654
+ - **URL values:** values restored from URL params (strings) match numeric options, and the emitted filter carries the option's own typed value.
655
+ - **Output:** choosing a value emits `[{ field, operator: "IN", value: [v] }]`; "All" emits `[]`.
656
+ - **Lookup targets:** when `field` is a FORM_SEARCH lookup on the target form, the filter matches the lookup's `.view` value. So `valueField` must be the field named as the lookup's **ViewLabel**.
657
+ - **Without a slot:** the panel renders a plain `<select>` for these controls.
658
+
397
659
  ---
398
660
 
399
661
  ## 5. Controlling the UI
@@ -449,9 +711,10 @@ Never re-derive values from `vm.raw`.
449
711
 
450
712
  | Family | Components | Type strings | `render` receives |
451
713
  | --- | --- | --- | --- |
452
- | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }` |
714
+ | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }`; `render` / `"KPI_CARD:card"` get `{ vms, byKey, roles, state, tokens, config }` |
453
715
  | Charts | `BarChartWidget` `LineChartWidget` `AreaChartWidget` `PieChartWidget` `DoughnutChartWidget` `ScatterChartWidget` | `BAR(_CHART)` `LINE(_CHART)` `AREA_CHART` `PIE(_CHART)` `DOUGHNUT(_CHART)` `SCATTER_CHART` | `{ rows, xKey, series, colorFor, toLabel, template, config, tokens, meta }` |
454
- | Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` |
716
+ | Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` (`ranking.order: "none"` keeps row order) |
717
+ | Funnel | `FunnelWidget` | `FUNNEL` | `{ rows: {label,value,ratioToFirst,stepPct,isFirst,index,raw}[], first, tokens, config, toLabel, onItemClick }` |
455
718
  | Gauge | `GaugeMeterWidget` | `GAUGE_METER` | `{ value, min, max, ratio, color, band, thresholds, rows }` |
456
719
  | Heat map | `HeatMapWidget` | `HEAT_MAP` | `{ cells: {zone,count,color,band}[], grid }` |
457
720
  | Tables (columns from `config.columns[]` as `{ label, dataPoint, type }`, the stored shape) | `DashboardTableWidget` `DatabaseTableWidget` `TaskTrackerTableWidget` `ChildTableWidget` `PivotGridWidget` | `TABLE` `DATABASE_TABLE` `TASK_TRACKER_TABLE` `CHILD_TABLE` `CHILD_DATABASE_TABLE` `PIVOTGRID` | `{ rows, columns, renderCell, toLabel, interactions, headerControls, getColumnOptions, enableSort, showColumnFilter, mode }` (+ `rowFields`, `columnFields`, `valueFields` for pivot) |
@@ -470,9 +733,10 @@ and HTML sanitization stay in the host app. The package never calls
470
733
  `dangerouslySetInnerHTML`.
471
734
 
472
735
  **`FilterPanelWidget` vs widget filters.** `FilterPanelWidget` is a
473
- *dashboard-level* control. It is controlled (`value` / `onChange`) and issues no
474
- queries. Feed its values into other widgets' `dashFilters`. A widget filter
475
- (§3) narrows only its own widget.
736
+ *dashboard-level* control. It is controlled (`value` / `onChange`), and it
737
+ issues no queries except one per `optionsSource`. Feed its values into other
738
+ widgets' `dashFilters`, or pass `onFiltersChange` output to their
739
+ `widgetFilters`. A widget filter (§3) narrows only its own widget.
476
740
 
477
741
  ---
478
742
 
@@ -650,6 +914,7 @@ writes widget code. Without it, Claude tends to write its own data fetching.
650
914
  - **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
915
  - **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
916
  - **Widget filters are AND-ed.** For OR logic, pass a host ZFilters payload through `localFilters`.
917
+ - **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
918
  - **Calendar / MeetingScheduler are simplified.** There is no drag-to-resize, month-grid layout or virtual scrolling. For full parity, wrap FullCalendar in `render`.
654
919
 
655
920
  ---
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
@@ -176,9 +191,10 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
176
191
 
177
192
  | Family | Components | `render` args |
178
193
  | --- | --- | --- |
179
- | KPI | `KpiWidget` | templates get `{ vm, index }`. `vm` is fully resolved; never read `vm.raw` |
194
+ | KPI | `KpiWidget` | templates get `{ vm, index }`. `vm` is fully resolved; never read `vm.raw`. Card: `layout: "card"` (root or `cardConfig`) + `contents[].role` (`main`/`sub`/`chip`/`series`); custom card via `renderCard` (only in card mode) |
180
195
  | Charts | `BarChartWidget` `LineChartWidget` `AreaChartWidget` `PieChartWidget` `DoughnutChartWidget` `ScatterChartWidget` | `{ rows, xKey, series, colorFor, toLabel, template, config, tokens, meta }` |
181
- | Ranking | `RankingBarWidget` | `{ rows: {rank,label,value,ratio}[], max }` |
196
+ | Ranking | `RankingBarWidget` | `{ rows: {rank,label,value,ratio}[], max }`; `ranking.order: "none"` keeps row order |
197
+ | Funnel | `FunnelWidget` (`FUNNEL`) | `{ rows: {label,value,ratioToFirst,stepPct,isFirst}[], first, tokens }`; `onItemClick(row)` |
182
198
  | Gauge | `GaugeMeterWidget` | `{ value, min, max, ratio, color, band }` |
183
199
  | Heat map | `HeatMapWidget` | `{ cells: {zone,count,color,band}[], grid }` |
184
200
  | Tables | `DashboardTableWidget` `DatabaseTableWidget` `TaskTrackerTableWidget` `ChildTableWidget` `PivotGridWidget` | `{ rows, columns, renderCell, toLabel, interactions, headerControls(col), getColumnOptions(key), enableSort, showColumnFilter, mode }` |
@@ -188,6 +204,17 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
188
204
  | Display | `HtmlWidget` `RawHtmlWidget` `IframeWidget` `GoogleMapWidget` `TreeWidget` `ActionCardWidget` | see types |
189
205
  | Containers | `TabWidget` `FormWidget` `FilterPanelWidget` `ZaiChatWidget` | see types |
190
206
 
207
+ Reshaping after the query (0.0.3). Use these instead of re-shaping rows in React:
208
+
209
+ ```tsx
210
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }} // one wide row → { label, value } per column, in order; missing → 0
211
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // every category in dropdown order, 0 where no bucket
212
+ <FunnelWidget dataSource={ONE_ROW} unpivot={{ columns: STAGES }} onItemClick={(r) => …} />
213
+ <FilterPanelWidget configurations={{ filters: [{ key, field: "Assignment", type: "select",
214
+ optionsSource: { dataSource, labelField, valueField, allLabel: "Firm-wide" } }] }}
215
+ onFiltersChange={(f) => setWidgetFilters(f)} /> // valueField = the lookup's ViewLabel field for FORM_SEARCH targets
216
+ ```
217
+
191
218
  **`render` is REQUIRED** for `ZaiChatWidget`, `GoogleMapWidget` and `HtmlWidget` markup. `FormWidget` renders `<ZvolvForm>` by default (see Forms). The package never calls `dangerouslySetInnerHTML`.
192
219
 
193
220
  In chart `render`, always use `xKey` and `series[].valueField` from the args, pass axis and user values through `toLabel`, and colour with `colorFor(i, s)`.