@zestl/zvolv-widgets 0.0.0 → 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 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`.
@@ -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
  ---
@@ -709,6 +861,8 @@ precision, prefix, suffix, accept, maxSizeKB, maxCount, childIds, pages, html, s
709
861
  | `section` `container` `tabs` `steps` `matrix` `table` | GROUP_VIEW subtypes, STEPPER, TAB, MATRIX, TABLE_START | `form.childrenOf(field)` + `<FormFields>` |
710
862
  | `sheet` | INPUT_SHEET, NESTED_FORM, INPUT_CHILD_WIDGET | `useSheetField` (rows are posted before the parent) |
711
863
  | `action` | ACTION_BUTTON, TRIGGER_AUTOMATION, CASCADED_ACTIONS | `form.runAction(field)` |
864
+ | `trials` | INPUT_PERCENT_CORRECT, INPUT_TIME_SAMPLING, INPUT_TASK_ANALYSIS (ABA trial widgets) | `useTrialField` (response / prompt buttons, trials, timer, steps, % correct, log) |
865
+ | `onbehalf` | ON_BEHALF_OF | read-only; `field.displayText` is the user's name |
712
866
  | `comments` `system` | COMMENTS, ON_BEHALF_OF, INPUT_IP, INPUT_DEVICE_INFO | system fields are filled automatically and never shown |
713
867
 
714
868
  **Dependent lookups.** A `FETCH_OPTIONS_FILTERS` rule sits on the lookup it filters, as in
@@ -719,19 +873,39 @@ value, clears the selection when the source changes, and auto-selects when exact
719
873
  (rule on a non-lookup driver, `elementId` = the lookup) is still read. `cascade(source, target, masterField)` writes
720
874
  the platform shape.
721
875
 
876
+ **Type coverage.** Every element type ZAI-Dashboard's FormFieldRouter and zvolv-web's z-form-widgets render has a kind. Aliases: `SCAN` → `INPUT_SCAN`, `HTML_TEXT` → `TEXT_VIEW`, `TRIGGER_AUTOMATION` → an action button, `ACTION_DROPDOWN` → an action with `field.variant === "dropdown"` whose `field.options` are its actions (`form.runAction(field, label)` runs only the picked one, as in zvolv-web). `INPUT_CARD_CONTAINER` is commented out in zvolv-web and is not supported. Like zvolv-web, any `attributes.subType` that is a known type replaces `type` — so an element saved as `INPUT_SHORT_TEXT` with subType `INPUT_PERCENT_CORRECT` (and `dataType: "OBJECT"`) is a trial widget. `DOWNLOAD_FILE` / `PRINT_PREVIEW` fall back to `properties.url` when the button has no value.
877
+
878
+ **Trial widgets** (`useTrialField(form, field)`): the configuration comes from the stored value's `conf` (ZAI) over `element.properties` (zvolv-web): `ResponseButtons` (with `PromptButtons`), `maximumNumberOfTrials`, `minimumNumberOfTrials`, `LinkedTimerField`, `timeLimit` / `timeInterval`, `steps` and `type` (forward / backward / whole). Values match ZAI: `{ conf, buttonResp, lastStatus? , currentStatus? }`. `minimumNumberOfTrials` is checked on submit for percent correct and time sampling.
879
+
722
880
  **Field parity with ZAI-Dashboard / zvolv-web.**
723
881
 
724
882
  - **Options load on focus.** Call `o.onFocus()` from your control's focus/open handler. Lookups and people search fetch nothing until then, as in zvolv-web (`nzFocus → onFocus`) and ZAI (`handleFocus`). Lookups re-fetch on later focuses (throttled to 2 s); people search re-fetches only when it has nothing loaded. Dependency-filtered lookups still load as soon as their source is set. Pass `useFieldOptions(form, field, { loadOn: "mount" })` for the old behaviour.
725
883
  - **Single vs multi** comes from `properties.maxResults` for FORM_SEARCH, INPUT_TAG and AUTO_COMPLETE (1 = single). `toggle` refuses picks past the limit; `o.maxCount` and `o.atLimit` tell the control.
726
884
  - **INPUT_TAG** can store a value that isn't in the list: `o.addFreeTag(text)` stores `{ view, select, extraLabels: [] }`.
727
885
  - **ExtraLabels auto-fill is off by default.** Neither platform copies a picked row's ExtraLabels into other fields; use a `FETCH_OPTIONS_FILTERS` dependent lookup. Opt in with `{ autofillExtraLabels: true }`.
886
+ - **Signed-in user is decrypted** like ZAI-Dashboard `getStoredUser()`: the host's `readUser`, else the SDK's `readUserFromStorage()`, else `client.auth.userInstance` through `decryptAuthObject`. Values still `enc::…` are dropped, and forms re-read the user on the SDK's `zvAuthCryptReady` event. Pass `createZvolvRuntime({ client, readUser: readUserFromStorage })` to be explicit.
728
887
  - **ON_BEHALF_OF** has kind `"onbehalf"`. It is shown read-only, stores the signed-in user's id (never a role id), shows `field.displayText` (the user's name), and isn't overwritten by automation `formData`.
729
888
  - **CHECK_BOX** with `attributes.showAsSwitch` gets `field.variant === "switch"`. `"1"` is read as true.
730
889
  - **DATE_PICKER** `dynamicDefault` supports `ADD` and `SUB`, any unit (`days`, `weeks`, `months`, `years`, `hours`), a bare number of days, and the `DynamicDefault` key. `field.dateFormat` is `properties.format` converted to dayjs tokens (default `DD/MM/YYYY`).
731
890
  - **RICH_TEXT_EDITOR**: markup with no text (`<p><br></p>`, `&nbsp;`) counts as empty and is sent as `null`. `field.maxLength` also reads `attributes.charactersCount`.
732
891
  - **FILE_UPLOAD_V2**: the count limit also reads `allowedFileMaxCount` and `properties.max` (1 for `ShowAsProfileImagePicker`). Blocked extensions (`.exe`, `.bat`, `.js`, … — `BLOCKED_FILE_EXTENSIONS`) are refused.
733
892
  - **INPUT_SHEET**: `useSheetField` returns `canAdd` / `canDelete` (from `allowedAddActions`, `allowedDeleteActions` or `allowedActions`), `maxRows` and `limitMessage`. Saved rows load without marking the form dirty. Row validation skips hidden, disabled and non-input columns and untouched saved rows.
734
- - **Action buttons** (`form.runAction`): `actionType` comes from `properties.actionType` or `subType`. `OPEN_URL` (default; falls back to `defaultValue`), `SUBMIT`, `TRIGGER_AUTOMATION` (also treats `code` 400/500 as an error), and `UPDATE` / `UPDATE_SUBMISSION` / `UPDATE_OPEN_NEW_FORM` / unknown types (set the button `true`, then save). `SAVE_SUBMISSION`, `CREATE_SUBMISSION`, `EDIT_SUBMISSION`, `OPEN_TASK`, `PRINT_PREVIEW`, `INVOKE_AGENT` and the like open other UI, so they go to your `onAction`; the library no longer submits the current form for `SAVE_SUBMISSION`.
893
+ - **Action buttons** (`form.runAction`) handle every actionType ZAI-Dashboard and zvolv-web do. `actionType` = `properties.actionType` ?? `attributes.subType` ?? `properties.subType` ?? `OPEN_URL`.
894
+
895
+ | actionType | What runs |
896
+ | --- | --- |
897
+ | `OPEN_URL` / `URL` | opens `properties.url`, the button's value, or `defaultValue` |
898
+ | `TRIGGER_AUTOMATION` | `invokeTriggers(triggerID)`; `automationType: "ZENO"` uses `invokeNuclioAutomation(automationUuid)`. `formData` is written back; `code` 400/500 or `error` is a failure; `refreshForm` / `refreshFormDelay` reloads a saved record |
899
+ | `INVOKE_AGENT` | `invokeAgent({ content: { query } }, flowId, agentId)` with a label-keyed body; the reply's `formData` is written back |
900
+ | `DOWNLOAD_FILE` | downloads the button's value (a URL), `properties.fileName` |
901
+ | `PRINT_PREVIEW` | loads the PDF at the button's value and opens the print dialog |
902
+ | `UPLOAD_FILE` | file picker → upload (card upload when `cardID` is set) → stores the file URL in the button |
903
+ | `UPDATE`, `UPDATE_SUBMISSION`, `UPDATE_OPEN_NEW_FORM`, `EDIT_OPEN_NEW_FORM`, unknown | sets the button `true`, then saves |
904
+ | `SUBMIT` | saves the form |
905
+ | `CREATE_SUBMISSION`, `EDIT_SUBMISSION`, `SAVE_SUBMISSION`, `OPEN_TASK`, `OPEN_PROJECT` | open other screens: handle in `onAction`; `openFormRequest(field, form)` gives `{ formId, submissionId, mode, defaultValues (inputMap) }` |
906
+ | `CASCADED_ACTIONS` | runs `actionEleIds` in order, stops at the first failure, then saves when `submit` is true |
907
+
908
+ `successMessage`, `errorMessage` and `hideSuccessMessage` shape the returned message; `form.reload()` re-fetches the record.
735
909
 
736
910
  **Show / hide / mandatory rules (triage).** These match ZAI-Dashboard's `applyDependencies`:
737
911
 
@@ -745,6 +919,24 @@ the platform shape.
745
919
  `formData` back into the form). Pass `onAction` to `<ZvolvForm>` for host actions such as
746
920
  "open another form".
747
921
 
922
+ ### Inspector (validation, rules, filters, formulas, payload)
923
+
924
+ `useFormInspector(form)` (or the pure `inspectForm(form)`) explains what the engine is doing for the current values. Every verdict comes from the same functions the form uses, so a host can draw an inspector panel without re-implementing anything:
925
+
926
+ | Key | What it holds |
927
+ | --- | --- |
928
+ | `rules` | each dependency / triage rule: source value, expected value, effect (show / hide / required / optional / editable / read-only), target, `matched` |
929
+ | `filters` | each lookup filter (FETCH_OPTIONS_FILTERS, DynamicFilters, StaticFilters) with the feeding value and `active` |
930
+ | `validation` | each input field and sheet cell: checks, current `error` from `validateField`, the error shown to the user |
931
+ | `formulas` | each form and sheet-row formula: inputs, `expected`, `current`, `inSync`, `serverSafe`, `serverFormula` |
932
+ | `defaults` | dynamic and fixed defaults and the value they produced |
933
+ | `payload` | the exact submission body — sheet rows (posted first) and the parent |
934
+ | `summary` | counts for tiles |
935
+
936
+ **Formulas must use `{Label}` references.** The submission API re-evaluates every INPUT_FORMULA and rejects a mismatch (`Evaluated value "0" and existing value "8000" … do not match`). It resolves only `{Label}`; bare labels (`MUL(Qty, Price)`) evaluate to 0 on the server even though the library computes them. `isServerSafeFormula(formula, labels)` detects this and `toBraceFormula` gives the fix; the inspector flags it per formula.
937
+
938
+ Sheet row formulas are recalculated by `useSheetField().updateRow` / `addRow` (`computeRowFormulas`), and again before rows are posted.
939
+
748
940
  ### Headless
749
941
 
750
942
  `useZvolvForm({ formId, mode, submissionId, defaultValues, hiddenFields, skipTypes, onSubmitted })`
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
@@ -222,7 +237,10 @@ Forms work like widgets: Zvolv owns the behaviour and you own the look.
222
237
 
223
238
  - Render with `<ZvolvForm formId … />`, or let a `FORM` / `FORM_WIDGET` dashboard widget do it.
224
239
  - Style every field once with `<FormKitSlot.Provider value={{ field, form, status, grid }}>`. Switch on `field.kind`, and use `useFieldOptions`, `useFileField`, `useSheetField` and `form.runAction` for behaviour. Never re-implement rules, formulas, lookups or the submit body.
240
+ - Sheet row formulas: call `useSheetField().updateRow` — it recalculates formula cells. For a debug/inspector panel use `useFormInspector(form)` (rules, filters, validation, formulas, defaults, payload).
241
+ - Write formulas with `{Label}` references (`MUL({Qty}, {Price})`). The submission API re-evaluates formulas and reads bare labels as 0, so `MUL(Qty, Price)` is rejected with "Evaluated value \"0\" … do not match". Check with `isServerSafeFormula`.
225
242
  - Nested layout: for `section`, `tabs` and `steps` kinds, render `<FormFields form={form} fields={form.childrenOf(field)} />`.
243
+ - Kind `"trials"` (ABA widgets) → draw with `useTrialField`; action `variant === "dropdown"` (ACTION_DROPDOWN) → a select of `field.options`, call `form.runAction(field, label)`.
226
244
  - Lookup / people controls must call `o.onFocus()` on focus — options load on focus (zvolv-web / ZAI parity). Single vs multi is `maxResults`; respect `o.atLimit`; INPUT_TAG free text via `o.addFreeTag`. ExtraLabels auto-fill is opt-in (`autofillExtraLabels`); prefer FETCH_OPTIONS_FILTERS. Draw kind `"onbehalf"` read-only with `field.displayText`; checkbox `variant === "switch"`; sheets honour `canAdd` / `canDelete` / `limitMessage`. SAVE_SUBMISSION and other open-UI actions need `onAction`.
227
245
  - Triage / show-hide-mandatory rules follow ZAI-Dashboard: `properties.triage` overrides `dependencies`; array values must match the selection exactly; a matching rule beats a non-matching one; hiding keeps the value; `field.error` appears only after submit and clears when the field changes. Don't add reset-on-hide or live "touched" errors in the app.
228
246
  - Dependent lookups (`FETCH_OPTIONS_FILTERS`) use the platform shape: the rule sits **on the dependent lookup** — `{ type: "FETCH_OPTIONS_FILTERS", elementId: <source field>, masterField: <label on its source form> }`. `useFieldOptions` filters by the source's value, reports `waitingFor` until it is set, clears the selection when it changes, and auto-selects when exactly one row matches. Build it with `cascade(source, target, masterField)`.