@zeyos/zx 4.4.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +42 -10
  3. package/dist/types/components/activity-list/activity-list.d.ts +22 -0
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +5 -3
  5. package/dist/types/components/avatar/avatar.d.ts +46 -7
  6. package/dist/types/components/badge/badge.d.ts +21 -1
  7. package/dist/types/components/button/button.d.ts +15 -0
  8. package/dist/types/components/chart/chart.d.ts +10 -0
  9. package/dist/types/components/checklist/checklist.d.ts +9 -0
  10. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  11. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  12. package/dist/types/components/datebox/datebox.d.ts +7 -2
  13. package/dist/types/components/dialog/dialog.d.ts +46 -6
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  16. package/dist/types/components/field/field.d.ts +24 -2
  17. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  18. package/dist/types/components/file-item/file-item.d.ts +34 -6
  19. package/dist/types/components/form/form.d.ts +12 -1
  20. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  21. package/dist/types/components/launcher/launcher.d.ts +260 -15
  22. package/dist/types/components/list-toolbar/list-toolbar.d.ts +1 -1
  23. package/dist/types/components/menu-button/menu-button.d.ts +33 -5
  24. package/dist/types/components/message/message.d.ts +119 -17
  25. package/dist/types/components/modal/modal.d.ts +13 -2
  26. package/dist/types/components/select/color.d.ts +12 -0
  27. package/dist/types/components/select/glyphs.d.ts +124 -0
  28. package/dist/types/components/select/select.d.ts +11 -3
  29. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  30. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  31. package/dist/types/components/table/table.d.ts +46 -1
  32. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  33. package/dist/types/components/tree/tree.d.ts +13 -0
  34. package/dist/types/core/breakpoint.d.ts +1 -1
  35. package/dist/types/core/icons.d.ts +23 -25
  36. package/dist/types/core/motion.d.ts +59 -0
  37. package/dist/types/core/overlay-host.d.ts +15 -1
  38. package/dist/types/index.d.ts +5 -3
  39. package/dist/types/zeyos/connect.d.ts +6 -0
  40. package/dist/types/zeyos/form.d.ts +50 -9
  41. package/dist/types/zeyos/index.d.ts +1 -1
  42. package/dist/types/zeyos/query.d.ts +79 -0
  43. package/dist/types/zeyos/schema.d.ts +24 -1
  44. package/dist/types/zeyos/table.d.ts +100 -14
  45. package/dist/types/zeyos/view.d.ts +10 -0
  46. package/dist/zx-zeyos.esm.js +1621 -443
  47. package/dist/zx-zeyos.esm.min.js +53 -5
  48. package/dist/zx.css +1411 -660
  49. package/dist/zx.esm.js +1976 -703
  50. package/dist/zx.esm.min.js +56 -8
  51. package/dist/zx.global.js +1978 -705
  52. package/dist/zx.global.min.js +56 -8
  53. package/dist/zx.min.css +1 -1
  54. package/docs/api.json +219 -34
  55. package/docs/llms.md +472 -110
  56. package/docs/llms.txt +3 -3
  57. package/package.json +1 -1
  58. package/src/components/account-menu/account-menu.css +35 -10
  59. package/src/components/activity-item/activity-item.css +1 -1
  60. package/src/components/activity-item/activity-item.js +3 -0
  61. package/src/components/activity-list/activity-list.css +38 -3
  62. package/src/components/activity-list/activity-list.js +34 -0
  63. package/src/components/app-icon/app-icon.css +1 -1
  64. package/src/components/app-rail/app-rail.css +40 -79
  65. package/src/components/app-sidebar/app-sidebar.css +109 -174
  66. package/src/components/app-sidebar/app-sidebar.js +21 -6
  67. package/src/components/aurora/aurora.css +5 -3
  68. package/src/components/avatar/avatar.css +32 -7
  69. package/src/components/avatar/avatar.js +72 -8
  70. package/src/components/badge/badge.css +54 -2
  71. package/src/components/badge/badge.js +29 -2
  72. package/src/components/button/button.css +67 -9
  73. package/src/components/button/button.js +38 -0
  74. package/src/components/calendar/calendar.css +46 -46
  75. package/src/components/card/card.css +2 -2
  76. package/src/components/card-view/card-view.css +9 -7
  77. package/src/components/chart/chart.css +4 -1
  78. package/src/components/chart/chart.js +12 -0
  79. package/src/components/check-button/check-button.css +10 -2
  80. package/src/components/checklist/checklist.css +9 -2
  81. package/src/components/checklist/checklist.js +11 -5
  82. package/src/components/checklist/field-adapter.js +3 -1
  83. package/src/components/code-editor/code-editor.css +6 -5
  84. package/src/components/code-editor/code-editor.js +19 -1
  85. package/src/components/context-menu/context-menu.css +9 -4
  86. package/src/components/copy/copy-button.css +6 -0
  87. package/src/components/copy/copy-input.css +8 -3
  88. package/src/components/data-filter/data-filter.css +5 -4
  89. package/src/components/date-picker/date-picker.css +6 -6
  90. package/src/components/date-picker/date-range-picker.css +3 -3
  91. package/src/components/date-picker/time-picker.css +1 -1
  92. package/src/components/datebox/date-range-box.css +79 -2
  93. package/src/components/datebox/date-range-box.js +13 -2
  94. package/src/components/datebox/datebox.css +79 -2
  95. package/src/components/datebox/datebox.js +13 -2
  96. package/src/components/dialog/dialog.css +26 -0
  97. package/src/components/dialog/dialog.js +99 -17
  98. package/src/components/dropdown/dropdown.css +1 -1
  99. package/src/components/dropdown/dropdown.js +5 -3
  100. package/src/components/empty-state/empty-state.css +2 -2
  101. package/src/components/entity-ref/entity-ref.css +1 -1
  102. package/src/components/entity-ref/entity-ref.js +1 -1
  103. package/src/components/field/field.css +28 -9
  104. package/src/components/field/field.js +68 -26
  105. package/src/components/field-upload/field-upload.css +1 -1
  106. package/src/components/fieldset/fieldset.css +5 -3
  107. package/src/components/fieldset/fieldset.js +28 -5
  108. package/src/components/file-item/file-item.js +144 -10
  109. package/src/components/filter/filter.js +7 -3
  110. package/src/components/filter-panel/filter-panel.css +1 -1
  111. package/src/components/finder/finder.css +15 -13
  112. package/src/components/form/form.css +5 -4
  113. package/src/components/form/form.js +11 -2
  114. package/src/components/grid/grid.css +1 -1
  115. package/src/components/groupbox/groupbox.css +1 -1
  116. package/src/components/kanban-view/kanban-card.js +11 -3
  117. package/src/components/kanban-view/kanban-view.css +39 -15
  118. package/src/components/kanban-view/kanban-view.js +4 -4
  119. package/src/components/launcher/launcher.css +107 -25
  120. package/src/components/launcher/launcher.js +503 -41
  121. package/src/components/list-toolbar/list-toolbar.css +13 -8
  122. package/src/components/list-toolbar/list-toolbar.js +1 -1
  123. package/src/components/master-panel/master-panel.css +2 -0
  124. package/src/components/menu-button/menu-button.css +9 -4
  125. package/src/components/menu-button/menu-button.js +65 -9
  126. package/src/components/message/message.css +33 -3
  127. package/src/components/message/message.js +217 -15
  128. package/src/components/modal/modal.js +18 -3
  129. package/src/components/navigation-bar/navigation-bar.css +18 -9
  130. package/src/components/number-field/field-adapter.js +1 -0
  131. package/src/components/number-field/number-field.css +70 -0
  132. package/src/components/number-field/number-field.js +2 -0
  133. package/src/components/pagination/pagination.css +8 -6
  134. package/src/components/panel/panel.css +1 -1
  135. package/src/components/rating/rating.css +7 -0
  136. package/src/components/select/color.js +22 -0
  137. package/src/components/select/field-adapter.js +2 -0
  138. package/src/components/select/glyphs.js +165 -0
  139. package/src/components/select/select.css +138 -45
  140. package/src/components/select/select.js +83 -47
  141. package/src/components/sheet/sheet.css +11 -8
  142. package/src/components/slider/slider.css +16 -2
  143. package/src/components/slider/slider.js +1 -1
  144. package/src/components/sort-control/sort-control.css +4 -1
  145. package/src/components/stat-tile/stat-tile.css +41 -46
  146. package/src/components/stat-tile/stat-tile.js +17 -6
  147. package/src/components/tabbox/tabbox.css +32 -27
  148. package/src/components/tabbox/tabbox.js +5 -1
  149. package/src/components/table/table.css +104 -29
  150. package/src/components/table/table.js +90 -16
  151. package/src/components/tag-picker/tag-picker.css +83 -5
  152. package/src/components/tag-picker/tag-picker.js +25 -8
  153. package/src/components/timebox/timebox.css +22 -2
  154. package/src/components/toggle/toggle.css +12 -1
  155. package/src/components/toolbar/toolbar.js +2 -2
  156. package/src/components/tooltip/tooltip.css +4 -0
  157. package/src/components/tooltip/tooltip.js +10 -3
  158. package/src/components/tree/tree.css +25 -16
  159. package/src/components/tree/tree.js +31 -0
  160. package/src/components/value-list/value-list.css +3 -3
  161. package/src/components/view/record-view.js +1 -1
  162. package/src/core/breakpoint.js +1 -1
  163. package/src/core/icons.js +193 -60
  164. package/src/core/motion.js +147 -0
  165. package/src/core/overlay-host.js +21 -2
  166. package/src/index.js +5 -3
  167. package/src/internal/app-rail.js +1 -2
  168. package/src/zeyos/connect.js +6 -2
  169. package/src/zeyos/form.js +67 -27
  170. package/src/zeyos/index.js +1 -1
  171. package/src/zeyos/query.js +153 -0
  172. package/src/zeyos/schema.js +144 -27
  173. package/src/zeyos/table.js +68 -5
  174. package/src/zeyos/view.js +6 -0
  175. package/styles/base.css +56 -0
  176. package/styles/icons.css +11 -1
  177. package/styles/tokens/dark.css +113 -37
  178. package/styles/tokens/global.css +121 -27
  179. package/styles/tokens/semantic.css +100 -31
  180. package/styles/tokens/themes.css +7 -6
  181. package/styles/zx.css +0 -1
  182. package/styles/tokens/density.css +0 -9
package/src/zeyos/form.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Form, Message, Select } from '../index.js';
2
- import { connect } from './connect.js';
3
- import { dateToUnixSeconds, unixSecondsToDate } from './query.js';
2
+ import { overlayHost } from '../core/overlay-host.js';
3
+ import { connect, errorMessage } from './connect.js';
4
+ import {
5
+ calendarDayToUnixSeconds, dateToUnixSeconds, unixSecondsToCalendarDay, unixSecondsToDate
6
+ } from './query.js';
4
7
  import { fieldToZxField, resolveFields } from './schema.js';
5
8
  import { buildZeyosSelectConfig } from './select.js';
6
9
 
@@ -9,11 +12,21 @@ import { buildZeyosSelectConfig } from './select.js';
9
12
  * @property {string[]} [fields] Curated ordered field allow-list.
10
13
  * @property {string[]} [exclude] Field ids to omit.
11
14
  * @property {Record<string, string>} [labels] Per-field label overrides.
15
+ * @property {Record<string, string>} [formats] Per-field semantic format overrides, such as
16
+ * `{ duedate: 'date' }` for a Unix-second field that holds a calendar day.
17
+ * @property {string} [timeZone] IANA time zone of the ZeyOS instance. A date-only field's stored
18
+ * midnight is read as the calendar day it names in this zone, and a picked day is saved as midnight
19
+ * in it; defaults to the runtime's own zone.
12
20
  * @property {string} [title=''] Fieldset legend.
13
21
  * @property {1|2|3} [columns=2] Fieldset column count.
14
22
  * @property {Record<string, unknown>} [value] Initial record using ZeyOS storage values.
15
23
  * @property {unknown} [id] Initial record id to load.
16
24
  * @property {(record: Record<string, unknown>) => unknown|Promise<unknown>} [onSaved] Success callback.
25
+ * @property {Element|string|null} [scope=null] Element or selector whose theme scope shows the
26
+ * binding's toasts — the save confirmation and the report of a failed request. Null uses the form's
27
+ * own element, so a form inside a scoped shell or dialog toasts inside that scope.
28
+ * @property {boolean} [notify=true] Whether the binding raises toasts. `false` keeps it silent:
29
+ * `save()`, `load()`, and `ready` still resolve or reject, and reporting is the caller's.
17
30
  */
18
31
 
19
32
  /**
@@ -22,8 +35,10 @@ import { buildZeyosSelectConfig } from './select.js';
22
35
  * @property {Record<string, Record<string, any>>} fields Field descriptors keyed by id.
23
36
  * @property {Array<{id: string, resource: string}>} entityFields Referenced-resource fields.
24
37
  * @property {Record<string, any>} formOptions Plain Zx Form options.
25
- * @property {(record: Record<string, unknown>) => Record<string, unknown>} mapFromRecord Maps Unix seconds to Date values.
26
- * @property {(values: Record<string, unknown>) => Record<string, unknown>} mapToRecord Maps Date and enum values to API storage values.
38
+ * @property {(record: Record<string, unknown>) => Record<string, unknown>} mapFromRecord Maps Unix
39
+ * seconds to Date values: a date-only field to local midnight of its calendar day in `timeZone`.
40
+ * @property {(values: Record<string, unknown>) => Record<string, unknown>} mapToRecord Maps Date and
41
+ * enum values to API storage values: a date-only field to midnight of its day in `timeZone`.
27
42
  */
28
43
 
29
44
  /**
@@ -31,7 +46,8 @@ import { buildZeyosSelectConfig } from './select.js';
31
46
  * @property {Form} form Generated Zx form.
32
47
  * @property {Promise<Record<string, unknown>|null>} ready Initial value/load readiness.
33
48
  * @property {(id: unknown) => Promise<Record<string, unknown>>} load Loads and maps one record.
34
- * @property {() => Promise<Record<string, unknown>|null>} save Validates and creates or updates the record.
49
+ * @property {() => Promise<Record<string, unknown>|null>} save Validates and creates or updates the
50
+ * record, then confirms with a toast in the form's scope unless `notify` is false.
35
51
  * @property {() => Form} getForm Returns the generated Zx Form.
36
52
  * @property {() => void} destroy Destroys the form and all owned fields/selects.
37
53
  */
@@ -39,7 +55,8 @@ import { buildZeyosSelectConfig } from './select.js';
39
55
  /**
40
56
  * Builds the DOM-free form configuration consumed by {@link zeyosForm}. Entity descriptors retain
41
57
  * the `zxselect` type and receive the same async options used by {@link zeyosSelect}; enum options
42
- * come from normalized schema metadata, and the returned mappers handle Unix-second dates.
58
+ * come from normalized schema metadata with readable labels, and the returned mappers handle
59
+ * Unix-second dates — date-only fields as calendar days in `opts.timeZone`.
43
60
  * @param {Record<string, any>} client Injected ZeyOS client instance.
44
61
  * @param {string} resource Resource name such as `transactions`.
45
62
  * @param {ZeyosFormOptions} [opts={}] Schema curation and layout options.
@@ -49,9 +66,14 @@ export function buildZeyosFormConfig(client, resource, opts = {}) {
49
66
  const fieldMeta = resolveFields(client, resource, opts);
50
67
  const fields = {};
51
68
  const entityFields = [];
69
+ /** @type {Map<string, 'date'|'datetime'>} */
70
+ const dateKinds = new Map();
52
71
 
53
72
  for (const meta of fieldMeta) {
54
73
  const descriptor = fieldToZxField(meta);
74
+ if (descriptor.type === 'date' || descriptor.type === 'datetime') {
75
+ dateKinds.set(String(meta.id), descriptor.type);
76
+ }
55
77
  const referencedResource = descriptor.type === 'zxselect' && typeof descriptor.props?.entity === 'string'
56
78
  ? descriptor.props.entity
57
79
  : null;
@@ -78,16 +100,18 @@ export function buildZeyosFormConfig(client, resource, opts = {}) {
78
100
  fields
79
101
  }]
80
102
  },
81
- mapFromRecord: (record) => mapFromRecord(record, fieldMeta),
82
- mapToRecord: (values) => mapToRecord(values, fieldMeta)
103
+ mapFromRecord: (record) => mapFromRecord(record, fieldMeta, dateKinds),
104
+ mapToRecord: (values) => mapToRecord(values, fieldMeta, dateKinds)
83
105
  };
84
106
  }
85
107
 
86
108
  /**
87
109
  * Generates a schema-driven Zx Form and a small async binding object. `load(id)` hydrates entity
88
110
  * labels and converts Unix seconds to Date objects. `save()` validates, maps values back to API
89
- * storage types, calls create/update through the WP14 connection facade, shows a success toast,
90
- * and invokes `onSaved(record)`. Await `ready` when `opts.id` or `opts.value` is supplied.
111
+ * storage types, calls create/update through the WP14 connection facade, invokes
112
+ * `onSaved(record)`, and shows a success toast. Its toasts — that confirmation and the report of a
113
+ * failed request — appear in the theme scope of `opts.scope` or of the form itself, and
114
+ * `notify: false` silences them. Await `ready` when `opts.id` or `opts.value` is supplied.
91
115
  * @param {Record<string, any>} client Injected ZeyOS client instance.
92
116
  * @param {string} resource Resource name such as `transactions`.
93
117
  * @param {ZeyosFormOptions} [opts={}] Schema, initial value, and save options.
@@ -97,8 +121,14 @@ export function zeyosForm(client, resource, opts = {}) {
97
121
  if (opts.onSaved != null && typeof opts.onSaved !== 'function') {
98
122
  throw new TypeError('zeyosForm onSaved must be a function');
99
123
  }
124
+ const notify = opts.notify !== false;
100
125
  const config = buildZeyosFormConfig(client, resource, opts);
101
- const facade = connect(client);
126
+ // The caller's scope, or the form's own element, decides where a toast appears — never whatever
127
+ // element happens to hold focus when a request settles.
128
+ const toastScope = () => opts.scope ?? form.el;
129
+ const facade = connect(client, {
130
+ onError: (error) => notify ? Message.error(errorMessage(error), { scope: toastScope() }) : undefined
131
+ });
102
132
  const form = new Form(null, config.formOptions);
103
133
  let currentId = opts.value?.ID ?? opts.id ?? null;
104
134
  let destroyed = false;
@@ -133,6 +163,8 @@ export function zeyosForm(client, resource, opts = {}) {
133
163
  if (destroyed) return Promise.reject(new Error('Cannot save a destroyed ZeyOS form'));
134
164
  if (savePending) return savePending;
135
165
  if (!form.submit()) return Promise.resolve(null);
166
+ // Resolved now, while the form is in place: `onSaved` may close the dialog that holds it.
167
+ const host = notify ? overlayHost(toastScope()) : null;
136
168
 
137
169
  savePending = (async () => {
138
170
  let payload;
@@ -157,7 +189,7 @@ export function zeyosForm(client, resource, opts = {}) {
157
189
  throw error;
158
190
  }
159
191
  }
160
- Message.success(`${recordLabel(record, resource)} saved successfully.`);
192
+ if (host) Message.success(`${recordLabel(record, resource)} saved successfully.`, { scope: host });
161
193
  return record;
162
194
  })().finally(() => {
163
195
  savePending = null;
@@ -183,27 +215,41 @@ export function zeyosForm(client, resource, opts = {}) {
183
215
  };
184
216
  }
185
217
 
186
- /** @param {Record<string, unknown>} record @param {Record<string, any>[]} meta @returns {Record<string, unknown>} */
187
- function mapFromRecord(record, meta) {
218
+ /**
219
+ * @param {Record<string, unknown>} record
220
+ * @param {Record<string, any>[]} meta
221
+ * @param {Map<string, 'date'|'datetime'>} dateKinds
222
+ * @returns {Record<string, unknown>}
223
+ */
224
+ function mapFromRecord(record, meta, dateKinds) {
188
225
  const values = {};
189
226
  for (const field of meta) {
190
227
  const id = String(field.id);
191
228
  const value = record[id];
192
- values[id] = isDateField(field) && value != null && value !== ''
193
- ? unixSecondsToDate(/** @type {number|string} */ (value))
194
- : value;
229
+ const kind = dateKinds.get(id);
230
+ if (!kind || value == null || value === '') values[id] = value;
231
+ else if (kind === 'date') values[id] = unixSecondsToCalendarDay(/** @type {number|string} */ (value), field.timeZone);
232
+ else values[id] = unixSecondsToDate(/** @type {number|string} */ (value));
195
233
  }
196
234
  return values;
197
235
  }
198
236
 
199
- /** @param {Record<string, unknown>} values @param {Record<string, any>[]} meta @returns {Record<string, unknown>} */
200
- function mapToRecord(values, meta) {
237
+ /**
238
+ * @param {Record<string, unknown>} values
239
+ * @param {Record<string, any>[]} meta
240
+ * @param {Map<string, 'date'|'datetime'>} dateKinds
241
+ * @returns {Record<string, unknown>}
242
+ */
243
+ function mapToRecord(values, meta, dateKinds) {
201
244
  const record = {};
202
245
  for (const field of meta) {
203
246
  const id = String(field.id);
204
247
  let value = values[id];
205
- if (isDateField(field)) {
206
- value = value == null || value === '' ? null : dateToUnixSeconds(/** @type {Date} */ (value));
248
+ const kind = dateKinds.get(id);
249
+ if (kind) {
250
+ if (value == null || value === '') value = null;
251
+ else if (kind === 'date') value = calendarDayToUnixSeconds(/** @type {Date} */ (value), field.timeZone);
252
+ else value = dateToUnixSeconds(/** @type {Date} */ (value));
207
253
  } else if (hasEnum(field) && isNumericType(field.type) && value !== '' && value != null) {
208
254
  const numeric = Number(value);
209
255
  if (Number.isFinite(numeric)) value = numeric;
@@ -213,12 +259,6 @@ function mapToRecord(values, meta) {
213
259
  return record;
214
260
  }
215
261
 
216
- /** @param {Record<string, any>} meta @returns {boolean} */
217
- function isDateField(meta) {
218
- const descriptor = fieldToZxField(meta);
219
- return descriptor.type === 'date' || descriptor.type === 'datetime';
220
- }
221
-
222
262
  /** @param {Record<string, any>} meta @returns {boolean} */
223
263
  function hasEnum(meta) {
224
264
  return meta.enums != null || meta.enum != null;
@@ -8,7 +8,7 @@ export {
8
8
  } from './schema.js';
9
9
  export {
10
10
  buildListQuery, tableSortToQuery, dataFilterStateToFilters,
11
- dateToUnixSeconds, unixSecondsToDate
11
+ dateToUnixSeconds, unixSecondsToDate, calendarDayToUnixSeconds, unixSecondsToCalendarDay
12
12
  } from './query.js';
13
13
  export { connect } from './connect.js';
14
14
  export {
@@ -55,6 +55,159 @@ export function unixSecondsToDate(seconds) {
55
55
  return new Date(value * 1000);
56
56
  }
57
57
 
58
+ /**
59
+ * @typedef {Object} CalendarDay
60
+ * @property {number} year Full year.
61
+ * @property {number} month Zero-based month, as `Date` counts them.
62
+ * @property {number} day Day of the month.
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} CalendarZone
67
+ * @property {string|undefined} timeZone Canonical IANA zone, or undefined for the runtime's own zone.
68
+ * @property {(milliseconds: number) => CalendarDay} dayOf Calendar day an instant falls on in the zone.
69
+ * @property {(year: number, month: number, day: number) => number} midnightOf Epoch milliseconds of
70
+ * the first moment of a calendar day in the zone (its midnight, or the first minute after a
71
+ * daylight-saving gap that swallows midnight).
72
+ */
73
+
74
+ /**
75
+ * Reads a ZeyOS date-only value as the calendar day it names. Date-only fields — an invoice's
76
+ * `date` and `duedate`, a contact's `birthdate` — hold Unix seconds at midnight of a day in the
77
+ * instance's time zone: a calendar day, not an instant. The result is midnight of that same day in
78
+ * the runtime's own zone, which is what Datebox and DatePicker edit, so the day survives a viewer in
79
+ * another zone and a daylight-saving change.
80
+ * @param {number|string|null|undefined} seconds Unix seconds; numeric strings are accepted.
81
+ * @param {string} [timeZone] IANA zone of the instance, such as `Europe/Vienna`; the runtime's own
82
+ * zone when omitted.
83
+ * @returns {Date|null} Local midnight of the day, or null for a nullish or empty input.
84
+ */
85
+ export function unixSecondsToCalendarDay(seconds, timeZone) {
86
+ if (seconds == null || seconds === '') return null;
87
+ const value = Number(seconds);
88
+ if (!Number.isFinite(value)) throw new TypeError('Expected finite Unix seconds');
89
+ const { year, month, day } = calendarZone(timeZone).dayOf(value * 1000);
90
+ return localMidnight(year, month, day);
91
+ }
92
+
93
+ /**
94
+ * Converts the calendar day of a Date to the Unix seconds a ZeyOS date-only field stores: midnight
95
+ * of that day in the instance's time zone. Only the Date's local year, month, and day count — its
96
+ * time of day is ignored — so a Datebox value round-trips through {@link unixSecondsToCalendarDay}.
97
+ * @param {Date|null|undefined} date Day to store.
98
+ * @param {string} [timeZone] IANA zone of the instance; the runtime's own zone when omitted.
99
+ * @returns {number|null} Unix seconds, or null for a nullish input.
100
+ */
101
+ export function calendarDayToUnixSeconds(date, timeZone) {
102
+ if (date == null) return null;
103
+ if (!(date instanceof Date)) throw new TypeError('Expected a Date');
104
+ if (!Number.isFinite(date.getTime())) throw new RangeError('Invalid Date');
105
+ const midnight = calendarZone(timeZone).midnightOf(date.getFullYear(), date.getMonth(), date.getDate());
106
+ return Math.floor(midnight / 1000);
107
+ }
108
+
109
+ /**
110
+ * Validates an IANA time-zone name.
111
+ * @param {unknown} timeZone Zone name; nullish or empty selects the runtime's own zone.
112
+ * @returns {string|undefined} The canonical zone name, or undefined for the runtime's own zone.
113
+ * @throws {RangeError} When the runtime does not know the zone.
114
+ */
115
+ export function normalizeTimeZone(timeZone) {
116
+ if (timeZone == null || timeZone === '') return undefined;
117
+ try {
118
+ return new Intl.DateTimeFormat('en-US', { timeZone: String(timeZone) }).resolvedOptions().timeZone;
119
+ } catch {
120
+ throw new RangeError(`Unknown time zone "${String(timeZone)}"`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Calendar arithmetic for one time zone. The `Intl` formatter is built once, so a table column can
126
+ * hold one zone for all of its cells.
127
+ * @param {string} [timeZone] IANA zone; the runtime's own zone when omitted.
128
+ * @returns {CalendarZone} Day lookup and midnight construction for the zone.
129
+ */
130
+ export function calendarZone(timeZone) {
131
+ const zone = normalizeTimeZone(timeZone);
132
+ if (!zone) {
133
+ return {
134
+ timeZone: undefined,
135
+ dayOf: (milliseconds) => {
136
+ const date = new Date(milliseconds);
137
+ return { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() };
138
+ },
139
+ midnightOf: (year, month, day) => localMidnight(year, month, day).getTime()
140
+ };
141
+ }
142
+
143
+ const formatter = new Intl.DateTimeFormat('en-US', {
144
+ timeZone: zone,
145
+ hourCycle: 'h23',
146
+ year: 'numeric',
147
+ month: 'numeric',
148
+ day: 'numeric',
149
+ hour: 'numeric',
150
+ minute: 'numeric',
151
+ second: 'numeric'
152
+ });
153
+ /** @param {number} milliseconds @returns {CalendarDay & {seconds: number}} */
154
+ const wallClock = (milliseconds) => {
155
+ const parts = {};
156
+ for (const part of formatter.formatToParts(new Date(milliseconds))) parts[part.type] = Number(part.value);
157
+ return {
158
+ year: parts.year,
159
+ month: parts.month - 1,
160
+ day: parts.day,
161
+ seconds: (parts.hour % 24) * 3600 + parts.minute * 60 + parts.second
162
+ };
163
+ };
164
+ /** @param {number} milliseconds @returns {number} The zone's UTC offset at that instant, in milliseconds. */
165
+ const offsetAt = (milliseconds) => {
166
+ const wall = wallClock(milliseconds);
167
+ return utcMidnight(wall.year, wall.month, wall.day) + wall.seconds * 1000
168
+ - Math.floor(milliseconds / 1000) * 1000;
169
+ };
170
+
171
+ return {
172
+ timeZone: zone,
173
+ dayOf: (milliseconds) => {
174
+ const { year, month, day } = wallClock(milliseconds);
175
+ return { year, month, day };
176
+ },
177
+ midnightOf: (year, month, day) => {
178
+ // Midnight is the instant whose wall clock reads the target day at 00:00. Two offset
179
+ // corrections settle it on either side of a daylight-saving change; where a change skips
180
+ // midnight altogether, the earlier candidate lands on the previous evening and the later one
181
+ // on the first minute of the day, which is the one kept.
182
+ const target = utcMidnight(year, month, day);
183
+ const first = target - offsetAt(target);
184
+ const second = target - offsetAt(first);
185
+ let best = null;
186
+ for (const candidate of [second, first]) {
187
+ const wall = wallClock(candidate);
188
+ if (wall.year !== year || wall.month !== month || wall.day !== day) continue;
189
+ if (!best || wall.seconds < best.seconds) best = { candidate, seconds: wall.seconds };
190
+ }
191
+ return best ? best.candidate : second;
192
+ }
193
+ };
194
+ }
195
+
196
+ /** @param {number} year @param {number} month @param {number} day @returns {Date} */
197
+ function localMidnight(year, month, day) {
198
+ const date = new Date(2000, 0, 1);
199
+ date.setFullYear(year, month, day);
200
+ date.setHours(0, 0, 0, 0);
201
+ return date;
202
+ }
203
+
204
+ /** @param {number} year @param {number} month @param {number} day @returns {number} */
205
+ function utcMidnight(year, month, day) {
206
+ const date = new Date(0);
207
+ date.setUTCFullYear(year, month, day);
208
+ return date.getTime();
209
+ }
210
+
58
211
  /**
59
212
  * Converts one or more Zx Table sort records to the signed field notation used by ZeyOS.
60
213
  * A missing sort produces an empty array.
@@ -1,4 +1,4 @@
1
- import { dateToUnixSeconds, unixSecondsToDate } from './query.js';
1
+ import { calendarZone, dateToUnixSeconds, normalizeTimeZone, unixSecondsToDate } from './query.js';
2
2
 
3
3
  /** @typedef {Record<string, unknown>} FieldMeta */
4
4
  /**
@@ -11,6 +11,13 @@ import { dateToUnixSeconds, unixSecondsToDate } from './query.js';
11
11
  * @property {string[]} [fields] Curated allow-list; its order becomes the output order.
12
12
  * @property {string[]} [exclude] Field ids to omit.
13
13
  * @property {Record<string, string>} [labels] Per-field label overrides.
14
+ * @property {Record<string, string>} [formats] Per-field semantic format overrides (`date`,
15
+ * `datetime`, `money`, or any registered key). The schema types a timestamp only by its storage,
16
+ * so this is how a Unix-second field the built-in rules misread is declared a calendar day
17
+ * (`date`) or an instant (`datetime`).
18
+ * @property {string} [timeZone] IANA time zone of the ZeyOS instance, such as `Europe/Vienna`.
19
+ * Date-only fields store midnight of a calendar day in this zone; it is copied onto every field's
20
+ * metadata as `timeZone`. Defaults to the runtime's own zone.
14
21
  */
15
22
 
16
23
  /**
@@ -22,6 +29,49 @@ import { dateToUnixSeconds, unixSecondsToDate } from './query.js';
22
29
  */
23
30
  export const TYPE_MAP = Object.create(null);
24
31
 
32
+ /**
33
+ * Unix-second fields ZeyOS stores as calendar days (midnight in the instance's zone) rather than
34
+ * instants: document and due dates, validity ranges, birthdays. `date` counts only when indexed —
35
+ * the unindexed ones (`imports`, `couponcodes`) record when something happened.
36
+ */
37
+ const DATE_ONLY_FIELDS = new Set(['date', 'duedate', 'birthdate', 'datefrom', 'dateto', 'datecancel', 'lastbilling']);
38
+
39
+ /**
40
+ * Fields with a date-only name that hold instants on their resource: appointment and event times,
41
+ * and when a stream record, comment, or document version was written.
42
+ */
43
+ const INSTANT_FIELDS = new Set([
44
+ 'appointments.datefrom', 'appointments.dateto', 'events.datefrom', 'events.dateto',
45
+ 'comments.date', 'documentversions.date', 'records.date'
46
+ ]);
47
+
48
+ /** Readable words for the ZeyOS enum constants that run several words together. */
49
+ const ENUM_WORDS = Object.freeze({
50
+ AWAITINGACCEPTANCE: 'awaiting acceptance',
51
+ AWAITINGAPPROVAL: 'awaiting approval',
52
+ BILLINGONLY: 'billing only',
53
+ CUSTOMERANDSUPPLIER: 'customer and supplier',
54
+ FEEDBACKREQUIRED: 'feedback required',
55
+ INEVALUATION: 'in evaluation',
56
+ INNEGOTIATION: 'in negotiation',
57
+ INREVISION: 'in revision',
58
+ NOSTOCK: 'no stock',
59
+ NOTSTARTED: 'not started',
60
+ PARTLYDELIVERED: 'partly delivered',
61
+ PARTLYINVOICED: 'partly invoiced',
62
+ PARTLYORDERED: 'partly ordered',
63
+ PARTLYPAID: 'partly paid',
64
+ PROCUREMENTONLY: 'procurement only',
65
+ REMOTECALL: 'remote call',
66
+ SERIALSANDCHARGES: 'serials and charges'
67
+ });
68
+
69
+ /** Labels for a priority field whose schema carries no enum. */
70
+ const PRIORITY_LABELS = Object.freeze({ 0: 'Lowest', 1: 'Low', 2: 'Medium', 3: 'High', 4: 'Highest' });
71
+
72
+ /** An enum name in the constant style ZeyOS uses for stored values (`INVOICED`, `BILLING_QUOTE`). */
73
+ const ENUM_CONSTANT = /^[A-Z][A-Z\d]*(?:_[A-Z\d]+)*$/;
74
+
25
75
  const warnedUnknownTypes = new Set();
26
76
 
27
77
  /**
@@ -74,7 +124,8 @@ export function fieldToZxField(meta) {
74
124
  /**
75
125
  * Converts normalized ZeyOS field metadata to a Zx Table column configuration. Renderers return
76
126
  * strings only (never DOM): references/enums show labels, numeric columns are right-aligned,
77
- * booleans show a check, and Unix seconds are formatted as dates.
127
+ * booleans show a check, and Unix seconds are formatted as dates — a date-only field as the
128
+ * calendar day it names in `meta.timeZone`, with no time; a date-time field with its time.
78
129
  * @param {FieldMeta} meta Normalized metadata containing at least `id` or `name`.
79
130
  * @returns {Record<string, unknown>} Plain Zx Table column descriptor.
80
131
  */
@@ -94,6 +145,8 @@ export function fieldToZxColumn(meta) {
94
145
  * client returns names from `schema.fields(resource)` and definitions from
95
146
  * `schema.describe(resource).fields`; embedded `enum` data is authoritative. A compatible
96
147
  * `schema.enums(resource, field)` is used when supplied by another client version/stub.
148
+ * `formats` overrides a field's semantic `format`, and `timeZone` is validated once and copied
149
+ * onto every field.
97
150
  * @param {Record<string, any>} client Injected `@zeyos/client` instance.
98
151
  * @param {string} resource Schema resource name such as `tickets`.
99
152
  * @param {ResolveFieldOptions} [opts={}] Curation options.
@@ -119,6 +172,8 @@ export function resolveFields(client, resource, opts = {}) {
119
172
  const requested = Array.isArray(opts.fields) ? opts.fields.map(String) : [...available.keys()];
120
173
  const excluded = new Set(Array.isArray(opts.exclude) ? opts.exclude.map(String) : []);
121
174
  const labels = opts.labels && typeof opts.labels === 'object' ? opts.labels : {};
175
+ const formats = opts.formats && typeof opts.formats === 'object' ? opts.formats : {};
176
+ const timeZone = normalizeTimeZone(opts.timeZone);
122
177
  const result = [];
123
178
 
124
179
  for (const id of requested) {
@@ -131,6 +186,8 @@ export function resolveFields(client, resource, opts = {}) {
131
186
  if (meta.enum == null) meta.enum = enumValues;
132
187
  }
133
188
  if (Object.prototype.hasOwnProperty.call(labels, id)) meta.label = String(labels[id]);
189
+ if (Object.prototype.hasOwnProperty.call(formats, id) && formats[id]) meta.format = String(formats[id]);
190
+ if (timeZone) meta.timeZone = timeZone;
134
191
  result.push(meta);
135
192
  }
136
193
  return result;
@@ -164,7 +221,7 @@ const enumColumn = {
164
221
  };
165
222
  const priorityColumn = {
166
223
  field: { type: 'zxselect', props: { preset: 'priority' } },
167
- column: (meta) => ({ render: enumRenderer(meta, priorityOptions()) })
224
+ column: (meta) => ({ render: enumRenderer(meta, PRIORITY_LABELS) })
168
225
  };
169
226
  const percentColumn = {
170
227
  field: { type: 'float', props: { min: 0, max: 100 } },
@@ -212,10 +269,13 @@ for (const type of ['email', 'tel', 'url', 'uri']) {
212
269
  function dateStrategy(type) {
213
270
  return {
214
271
  field: { type },
215
- column: (meta) => ({
216
- render: (row) => formatUnixDate(rowValue(row, meta), type === 'datetime'),
217
- sortValue: (row) => unixSortValue(rowValue(row, meta))
218
- })
272
+ column: (meta) => {
273
+ const format = type === 'datetime' ? instantFormatter() : calendarDayFormatter(meta.timeZone);
274
+ return {
275
+ render: (row) => format(rowValue(row, meta)),
276
+ sortValue: (row) => unixSortValue(rowValue(row, meta))
277
+ };
278
+ }
219
279
  };
220
280
  }
221
281
 
@@ -241,9 +301,8 @@ function strategyKey(meta) {
241
301
 
242
302
  const raw = normalizeType(meta.type);
243
303
  if (raw === 'bigint' || raw === 'int64') {
244
- const id = String(meta.id).toLowerCase();
245
- if (id === 'date' && meta.indexed) return 'date';
246
- if (/(?:date|time)$/.test(id)) return 'datetime';
304
+ const temporal = timestampKind(meta);
305
+ if (temporal) return temporal;
247
306
  }
248
307
  if (isLongText(meta, raw)) return 'longtext';
249
308
  if (raw && TYPE_MAP[raw]) return raw;
@@ -255,6 +314,22 @@ function strategyKey(meta) {
255
314
  return raw || semantic;
256
315
  }
257
316
 
317
+ /**
318
+ * Tells a calendar-day timestamp from an instant by the field's name and resource, since the schema
319
+ * types both as `bigint`.
320
+ * @param {FieldMeta} meta
321
+ * @returns {'date'|'datetime'|null}
322
+ */
323
+ function timestampKind(meta) {
324
+ const id = String(meta.id).toLowerCase();
325
+ const resource = String(meta.resource ?? '').toLowerCase();
326
+ if (DATE_ONLY_FIELDS.has(id) && !INSTANT_FIELDS.has(`${resource}.${id}`)) {
327
+ if (id !== 'date' || meta.indexed) return 'date';
328
+ }
329
+ if (INSTANT_FIELDS.has(`${resource}.${id}`) || /(?:date|time)$|^lastmodified$/.test(id)) return 'datetime';
330
+ return null;
331
+ }
332
+
258
333
  /** @param {FieldMeta} meta @returns {FieldMeta & {id: string}} */
259
334
  function normalizeMeta(meta) {
260
335
  if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw new TypeError('Field metadata must be an object');
@@ -362,31 +437,41 @@ function normalizeEnumOptions(values) {
362
437
  return Object.fromEntries(values.map((entry, index) => {
363
438
  if (Array.isArray(entry)) {
364
439
  const [value = index, label = value] = entry;
365
- return [String(value), String(label)];
440
+ return [String(value), enumLabel(label)];
366
441
  }
367
442
  if (entry && typeof entry === 'object') {
368
443
  const value = entry.value ?? entry.id ?? index;
369
- return [String(value), String(entry.label ?? entry.name ?? value)];
444
+ return [String(value), entry.label != null ? String(entry.label) : enumLabel(entry.name ?? value)];
370
445
  }
371
- return [String(index), String(entry)];
446
+ return [String(index), enumLabel(entry)];
372
447
  }));
373
448
  }
374
449
  if (values && typeof values === 'object') {
375
- return Object.fromEntries(Object.entries(values).map(([value, label]) => [value, String(label)]));
450
+ return Object.fromEntries(Object.entries(values).map(([value, label]) => [value, enumLabel(label)]));
376
451
  }
377
452
  return {};
378
453
  }
379
454
 
455
+ /**
456
+ * Turns a stored enum name into its label. A label the schema gives (`{value, label}`) never reaches
457
+ * this; what does is usually a constant such as `INVOICED` or `PARTLYPAID_CANCELLED`, shown in
458
+ * sentence case with its words separated ("Invoiced", "Partly paid cancelled"). Anything else is
459
+ * already a label and is kept as it is.
460
+ * @param {unknown} name Stored enum name.
461
+ * @returns {string}
462
+ */
463
+ function enumLabel(name) {
464
+ const text = String(name ?? '');
465
+ if (!ENUM_CONSTANT.test(text)) return text;
466
+ const words = text.split('_').map((part) => ENUM_WORDS[part] ?? part.toLowerCase()).join(' ');
467
+ return words.charAt(0).toUpperCase() + words.slice(1);
468
+ }
469
+
380
470
  /** @param {FieldMeta} meta @returns {'optionlist'|'select'} */
381
471
  function enumFieldType(meta) {
382
472
  return Object.keys(enumOptions(meta)).length <= 5 ? 'optionlist' : 'select';
383
473
  }
384
474
 
385
- /** @returns {Record<string, string>} */
386
- function priorityOptions() {
387
- return { '0': 'LOWEST', '1': 'LOW', '2': 'MEDIUM', '3': 'HIGH', '4': 'HIGHEST' };
388
- }
389
-
390
475
  /** @param {FieldMeta} meta @param {Record<string, string>} [fallback] @returns {(row: Record<string, unknown>) => string} */
391
476
  function enumRenderer(meta, fallback = {}) {
392
477
  const labels = { ...fallback, ...enumOptions(meta) };
@@ -426,17 +511,49 @@ function formatPercent(value) {
426
511
  return number == null ? '' : `${new Intl.NumberFormat().format(number)}%`;
427
512
  }
428
513
 
429
- /** @param {unknown} value @param {boolean} includeTime @returns {string} */
430
- function formatUnixDate(value, includeTime) {
431
- if (value == null || value === '') return '';
514
+ /**
515
+ * Formats a date-time field: the instant, in the viewer's zone, with its time.
516
+ * @returns {(value: unknown) => string}
517
+ */
518
+ function instantFormatter() {
519
+ const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
520
+ return (value) => {
521
+ const date = timestampDate(value);
522
+ return date ? formatter.format(date) : '';
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Formats a date-only field: the calendar day the stored midnight names in the instance's zone,
528
+ * with no time, so neither the viewer's zone nor daylight saving can move it.
529
+ * @param {unknown} timeZone Instance zone from the field metadata; the runtime's own when absent.
530
+ * @returns {(value: unknown) => string}
531
+ */
532
+ function calendarDayFormatter(timeZone) {
533
+ const zone = calendarZone(/** @type {string|undefined} */ (timeZone ?? undefined));
534
+ // The day is formatted as a UTC date so the formatter cannot shift it a second time.
535
+ const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeZone: 'UTC' });
536
+ return (value) => {
537
+ const date = timestampDate(value);
538
+ if (!date) return '';
539
+ // A Date value is already a local calendar day (a form's own value, say); seconds are stored.
540
+ const { year, month, day } = value instanceof Date
541
+ ? { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() }
542
+ : zone.dayOf(date.getTime());
543
+ const utc = new Date(0);
544
+ utc.setUTCFullYear(year, month, day);
545
+ return formatter.format(utc);
546
+ };
547
+ }
548
+
549
+ /** @param {unknown} value Unix seconds or a Date. @returns {Date|null} */
550
+ function timestampDate(value) {
551
+ if (value == null || value === '') return null;
432
552
  try {
433
553
  const date = value instanceof Date ? value : unixSecondsToDate(/** @type {number|string} */ (value));
434
- if (!date || !Number.isFinite(date.getTime())) return '';
435
- return new Intl.DateTimeFormat(undefined, includeTime
436
- ? { dateStyle: 'medium', timeStyle: 'short' }
437
- : { dateStyle: 'medium' }).format(date);
554
+ return date && Number.isFinite(date.getTime()) ? date : null;
438
555
  } catch {
439
- return '';
556
+ return null;
440
557
  }
441
558
  }
442
559