@object-ui/core 17.6.0 → 18.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 (147) hide show
  1. package/CHANGELOG.md +7260 -0
  2. package/README.md +228 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +473 -69
  6. package/dist/actions/ActionRunner.js +792 -125
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/UndoManager.d.ts +8 -13
  9. package/dist/actions/actionKeys.d.ts +9 -7
  10. package/dist/actions/actionKeys.js +67 -14
  11. package/dist/actions/actionResponse.js +2 -2
  12. package/dist/actions/index.d.ts +2 -0
  13. package/dist/actions/index.js +2 -0
  14. package/dist/actions/navigationTarget.d.ts +41 -0
  15. package/dist/actions/navigationTarget.js +20 -0
  16. package/dist/actions/recordDelete.d.ts +154 -0
  17. package/dist/actions/recordDelete.js +110 -0
  18. package/dist/adapters/ApiDataSource.d.ts +8 -4
  19. package/dist/adapters/ApiDataSource.js +19 -7
  20. package/dist/adapters/ValueDataSource.d.ts +6 -1
  21. package/dist/adapters/ValueDataSource.js +1090 -98
  22. package/dist/adapters/batchTransaction.d.ts +1 -1
  23. package/dist/adapters/batchTransaction.js +1 -1
  24. package/dist/adapters/resolveDataSource.d.ts +9 -6
  25. package/dist/adapters/resolveDataSource.js +9 -12
  26. package/dist/builder/schema-builder.d.ts +38 -81
  27. package/dist/builder/schema-builder.js +33 -152
  28. package/dist/data-scope/ViewDataProvider.d.ts +8 -25
  29. package/dist/data-scope/ViewDataProvider.js +27 -54
  30. package/dist/data-scope/element-data-source.d.ts +261 -7
  31. package/dist/data-scope/element-data-source.js +342 -52
  32. package/dist/data-scope/index.d.ts +9 -4
  33. package/dist/data-scope/index.js +9 -4
  34. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  35. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  36. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  37. package/dist/evaluator/FormulaFunctions.js +166 -27
  38. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  39. package/dist/evaluator/deadlineSettled.js +45 -0
  40. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  41. package/dist/evaluator/declaredPredicate.js +117 -16
  42. package/dist/evaluator/fieldRules.d.ts +88 -4
  43. package/dist/evaluator/fieldRules.js +208 -31
  44. package/dist/evaluator/index.d.ts +2 -0
  45. package/dist/evaluator/index.js +2 -0
  46. package/dist/evaluator/listConditional.d.ts +103 -34
  47. package/dist/evaluator/listConditional.js +127 -50
  48. package/dist/evaluator/optionRules.d.ts +77 -6
  49. package/dist/evaluator/optionRules.js +87 -10
  50. package/dist/evaluator/predicateInput.d.ts +15 -4
  51. package/dist/evaluator/predicateInput.js +35 -2
  52. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  53. package/dist/evaluator/rowPredicateCanon.js +65 -115
  54. package/dist/evaluator/subjectPermissions.d.ts +105 -0
  55. package/dist/evaluator/subjectPermissions.js +86 -0
  56. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  57. package/dist/evaluator/unevaluablePredicate.js +70 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +83 -2
  60. package/dist/protocols/index.d.ts +0 -1
  61. package/dist/protocols/index.js +9 -1
  62. package/dist/registry/Registry.d.ts +382 -34
  63. package/dist/registry/Registry.js +247 -12
  64. package/dist/registry/WidgetRegistry.js +7 -3
  65. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  66. package/dist/registry/html-tier-intrinsics.js +110 -0
  67. package/dist/registry/public-blocks.d.ts +8 -0
  68. package/dist/registry/public-blocks.js +57 -0
  69. package/dist/theme/ThemeEngine.js +1 -1
  70. package/dist/utils/affordanceGrants.d.ts +371 -0
  71. package/dist/utils/affordanceGrants.js +252 -0
  72. package/dist/utils/chart-category-key.d.ts +82 -0
  73. package/dist/utils/chart-category-key.js +84 -0
  74. package/dist/utils/chart-measure-key.d.ts +64 -0
  75. package/dist/utils/chart-measure-key.js +65 -0
  76. package/dist/utils/chart-presentation.d.ts +95 -112
  77. package/dist/utils/chart-presentation.js +169 -104
  78. package/dist/utils/chart-series.d.ts +165 -5
  79. package/dist/utils/chart-series.js +123 -18
  80. package/dist/utils/column-sortability.d.ts +157 -0
  81. package/dist/utils/column-sortability.js +217 -0
  82. package/dist/utils/dashboard-filters.d.ts +38 -6
  83. package/dist/utils/dashboard-filters.js +107 -61
  84. package/dist/utils/dataset-format.d.ts +211 -30
  85. package/dist/utils/dataset-format.js +389 -46
  86. package/dist/utils/date-display.d.ts +346 -0
  87. package/dist/utils/date-display.js +644 -0
  88. package/dist/utils/date-macros.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +81 -9
  94. package/dist/utils/expand-fields.js +81 -9
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +158 -6
  98. package/dist/utils/filter-converter.js +1098 -24
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -33
  101. package/dist/utils/grouping-fields.d.ts +61 -0
  102. package/dist/utils/grouping-fields.js +84 -0
  103. package/dist/utils/managedBy.d.ts +3 -3
  104. package/dist/utils/managedBy.js +5 -5
  105. package/dist/utils/native-date-value.d.ts +48 -0
  106. package/dist/utils/native-date-value.js +167 -0
  107. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  108. package/dist/utils/non-grid-row-ceiling.js +141 -0
  109. package/dist/utils/normalize-list-view.d.ts +135 -11
  110. package/dist/utils/normalize-list-view.js +243 -64
  111. package/dist/utils/number-display.d.ts +63 -14
  112. package/dist/utils/number-display.js +34 -12
  113. package/dist/utils/object-aggregate-query.d.ts +113 -0
  114. package/dist/utils/object-aggregate-query.js +50 -0
  115. package/dist/utils/parent-scope.d.ts +123 -0
  116. package/dist/utils/parent-scope.js +140 -0
  117. package/dist/utils/predicate-fields.d.ts +45 -12
  118. package/dist/utils/predicate-fields.js +141 -12
  119. package/dist/utils/record-color.d.ts +75 -0
  120. package/dist/utils/record-color.js +65 -0
  121. package/dist/utils/record-source.d.ts +298 -0
  122. package/dist/utils/record-source.js +331 -0
  123. package/dist/utils/record-title.d.ts +148 -8
  124. package/dist/utils/record-title.js +220 -26
  125. package/dist/utils/reference-keys.d.ts +21 -22
  126. package/dist/utils/reference-keys.js +532 -29
  127. package/dist/utils/sort-query.d.ts +115 -15
  128. package/dist/utils/sort-query.js +240 -13
  129. package/dist/utils/text-comparand.d.ts +92 -0
  130. package/dist/utils/text-comparand.js +99 -0
  131. package/dist/utils/without-denied-fields.d.ts +49 -0
  132. package/dist/utils/without-denied-fields.js +52 -0
  133. package/dist/validation/index.d.ts +8 -4
  134. package/dist/validation/index.js +8 -4
  135. package/dist/validation/schema-validator.d.ts +1 -1
  136. package/dist/validation/schema-validator.js +225 -83
  137. package/dist/validation/server-owned-value.d.ts +5 -1
  138. package/dist/validation/server-owned-value.js +5 -1
  139. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  140. package/dist/validation/validators/object-validation-engine.js +14 -3
  141. package/package.json +8 -9
  142. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  143. package/dist/data-scope/DataScopeManager.js +0 -229
  144. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  145. package/dist/protocols/ResponsiveProtocol.js +0 -158
  146. package/dist/validation/validation-engine.d.ts +0 -88
  147. package/dist/validation/validation-engine.js +0 -434
@@ -0,0 +1,644 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * date-display - the ONE date/datetime display path behind every field cell,
10
+ * grid card, gantt tooltip and dataset measure in the console.
11
+ *
12
+ * These functions are unchanged; what changed is where they live. They were
13
+ * written in `@object-ui/fields`' barrel (`packages/fields/src/index.tsx`),
14
+ * which is a React package, so `@object-ui/core` could not reach them - and
15
+ * `core`'s `utils/dataset-format.ts` is exactly the caller that needed them:
16
+ * `formatMeasure` returned `String(v)` for any non-numeric value, so a `min` /
17
+ * `max` measure over a date field rendered its raw 24-character ISO string on
18
+ * the metric tile, in chart values, in dataset table cells and in the
19
+ * metadata-admin dataset preview (objectui#7178).
20
+ *
21
+ * The fix is the move, NOT a second formatter here. That choice is the whole
22
+ * point, and it is the lesson of objectui#4576: when the percent convention
23
+ * was duplicated across this same package boundary, a list cell and a
24
+ * dashboard measure drifted apart (`1.234,5 %` beside `1.234,5%` in a German
25
+ * session) while both were "correct". `dataset-format.ts`'s own header records
26
+ * that history, and `number-display.ts` next door is the same remedy applied
27
+ * to numbers: the pure function moves DOWN into the React-free engine and the
28
+ * upper package re-exports it, so there is one home and nothing to drift.
29
+ *
30
+ * `@object-ui/fields` re-exports the formatting symbols below under their
31
+ * original names, so `formatDate` / `formatDateTime` /
32
+ * `formatDateTimeCompactParts` / `formatRelativeDate` / `DateDisplayOptions`
33
+ * keep working unchanged for `ObjectGrid`, `ObjectGantt`,
34
+ * `plugin-dashboard`'s `recordFields` and the `date` cell renderer.
35
+ * `toDisplayDate` is exported from `@object-ui/core` alone (objectui#10183),
36
+ * and so are `toDisplayEndDate` / `toInclusiveEndDay`, the end read and its
37
+ * inverse both gantt surfaces share (objectui#11141).
38
+ *
39
+ * The `datetime` CELL face joined this file in objectui#7443. It used to be a
40
+ * second convention inlined in `DateTimeCellRenderer`: two `Intl` option bags
41
+ * for one field type, kept in step by nothing, while `date` had exactly one.
42
+ * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the
43
+ * cell rendered before.
44
+ *
45
+ * No React, no i18n: the ambient inputs are `Intl`, the clock and the display
46
+ * zone below, and the one phrase `Intl` cannot produce ("Overdue Nd") comes
47
+ * in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers`
48
+ * in `dataset-format.ts` takes `fieldLabel`.
49
+ *
50
+ * ⚠️ There is a third ambient input, and it is the one this module has to
51
+ * decide about rather than pass on: the zone an instant renders in. A value
52
+ * carrying a time is an instant and renders in the DISPLAY ZONE — the zone the
53
+ * host declared through {@link setDisplayTimeZone} (objectui#11693), else the
54
+ * viewer's own; a DATE-ONLY value names a calendar day, carries no instant,
55
+ * and must render as that day everywhere, whatever the display zone.
56
+ * `toDisplayDate` below is the single parse step that tells the two apart —
57
+ * every function here goes through it (objectui#10110), and so does every
58
+ * caller elsewhere that needs a face or a day comparison none of these
59
+ * functions produce (objectui#10183).
60
+ */
61
+ // ── The display zone (objectui#11693) ──────────────────────────────────────
62
+ /**
63
+ * The zone every instant face below renders in, when the host has declared
64
+ * one; `undefined` means the viewer's own zone (the runtime default), which is
65
+ * what every face rendered in before objectui#11693.
66
+ */
67
+ let displayTimeZone;
68
+ const displayTimeZoneListeners = new Set();
69
+ /** `true` when this runtime's `Intl` knows `timeZone`. */
70
+ function isKnownTimeZone(timeZone) {
71
+ try {
72
+ new Intl.DateTimeFormat('en-US', { timeZone });
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Declare the zone instants render in — the host's half of objectui#11693.
81
+ *
82
+ * ## Why the zone enters HERE and nowhere else
83
+ *
84
+ * Every date and datetime face in the console is a function of this module
85
+ * (see the header), and each caller threads only its `locale`. The zone the
86
+ * signed-in workspace configures (`localization.timezone`, served by
87
+ * `GET /api/v1/auth/me/localization`) is the same for every face on the
88
+ * page, so it is set once, here, and every face reads it — rather than
89
+ * threaded through each call site, where one forgotten caller renders the
90
+ * same instant in a second zone beside the first (objectui#4272 is that
91
+ * defect for the locale). `LocalizationProvider` (`@object-ui/i18n`) is the
92
+ * host that calls this; a renderer never does.
93
+ *
94
+ * `undefined` (or an empty string) clears it: instants render in the
95
+ * viewer's zone again. A name this runtime's `Intl` does not know — a zone
96
+ * newer than the browser's time-zone data, say — clears it too, with a
97
+ * warning, rather than make every face throw the `RangeError` `Intl` raises
98
+ * for it.
99
+ *
100
+ * ⛔ A date-only value never takes the zone; see {@link toDisplayDate}.
101
+ */
102
+ export function setDisplayTimeZone(timeZone) {
103
+ let next;
104
+ if (timeZone) {
105
+ if (isKnownTimeZone(timeZone))
106
+ next = timeZone;
107
+ else
108
+ console.warn(`[date-display] unknown time zone "${timeZone}"; instants render in the viewer's zone`);
109
+ }
110
+ if (next === displayTimeZone)
111
+ return;
112
+ displayTimeZone = next;
113
+ for (const listener of [...displayTimeZoneListeners])
114
+ listener();
115
+ }
116
+ /** The zone {@link setDisplayTimeZone} declared, or `undefined` for the viewer's own. */
117
+ export function getDisplayTimeZone() {
118
+ return displayTimeZone;
119
+ }
120
+ /**
121
+ * Be told when the display zone changes; returns the unsubscribe. The shape is
122
+ * React's `useSyncExternalStore` contract, which is how `LocalizationProvider`
123
+ * re-renders its consumers once the zone they format in has moved.
124
+ */
125
+ export function subscribeDisplayTimeZone(listener) {
126
+ displayTimeZoneListeners.add(listener);
127
+ return () => {
128
+ displayTimeZoneListeners.delete(listener);
129
+ };
130
+ }
131
+ /**
132
+ * The `Date`s this module built to NAME A CALENDAR DAY — {@link toDisplayDate}'s
133
+ * local midnight for a date-only value, and {@link toInclusiveEndDay}'s day.
134
+ *
135
+ * The faces below must read such a `Date` in the local zone (its own docblock
136
+ * says so), never in the display zone: west of the viewer it would name the
137
+ * day before. A string still says what it is, but callers hand these `Date`s
138
+ * on — `GridField`'s date cell formats the one `toDisplayDate` returned, and
139
+ * the functions here hand theirs to each other — so the fact travels with the
140
+ * object rather than with the string it came from.
141
+ */
142
+ const calendarDays = new WeakSet();
143
+ /** The zone a parsed value renders in: the display zone for an instant, the viewer's for a calendar day. */
144
+ function zoneFor(date) {
145
+ return calendarDays.has(date) ? undefined : displayTimeZone;
146
+ }
147
+ /** `Intl` options naming `timeZone`, or none, so a face with no zone keeps its exact options bag. */
148
+ function inZone(timeZone) {
149
+ return timeZone ? { timeZone } : {};
150
+ }
151
+ const dayPartsFormats = new Map();
152
+ /** The calendar day `date` falls on in `timeZone` (the viewer's when `undefined`); `month` is 0-based. */
153
+ function dayIn(date, timeZone) {
154
+ if (!timeZone)
155
+ return { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() };
156
+ let format = dayPartsFormats.get(timeZone);
157
+ if (!format) {
158
+ format = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' });
159
+ dayPartsFormats.set(timeZone, format);
160
+ }
161
+ const parts = format.formatToParts(date);
162
+ const part = (type) => Number(parts.find((p) => p.type === type)?.value);
163
+ return { year: part('year'), month: part('month') - 1, day: part('day') };
164
+ }
165
+ /** A day as a whole number of days, so two days subtract to their distance with no DST hour in it. */
166
+ function dayNumber({ year, month, day }) {
167
+ const probe = new Date(0);
168
+ probe.setUTCFullYear(year, month, day);
169
+ return Math.round(probe.getTime() / 86400000);
170
+ }
171
+ /**
172
+ * The date-only ISO spelling — `2026-08-01`, and nothing else.
173
+ *
174
+ * Spelled exactly as `dataset-format.ts`'s `ISO_DATE_ONLY_RE`, which sniffs
175
+ * the same shape one file over to decide which arm a measure takes. Two
176
+ * spellings of one convention is what this module exists to prevent, so the
177
+ * two regexes are kept identical on purpose.
178
+ */
179
+ const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
180
+ /**
181
+ * An ISO date carrying a time part — `2026-08-01T09:30:00Z`, the same with a
182
+ * space separator, with or without seconds or an offset. Matched only as far
183
+ * as `HH:mm`, so its first ten characters are the `YYYY-MM-DD` the value was
184
+ * written on.
185
+ *
186
+ * Spelled exactly as `dataset-format.ts`'s `ISO_DATETIME_RE`, which sniffs the
187
+ * same shape to route a measure to its datetime arm, for the same reason as
188
+ * {@link ISO_DATE_ONLY_RE} above: one convention, one spelling.
189
+ */
190
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
191
+ /**
192
+ * A date that exists — the pattern alone would accept `2024-02-31`.
193
+ *
194
+ * `true` only for a date-only ISO string (`YYYY-MM-DD`, the shape
195
+ * {@link ISO_DATE_ONLY_RE} names) whose year, month and day read back
196
+ * unchanged from the calendar. Anything else — another shape, a time part, an
197
+ * out-of-range month, or a day its month does not have — is `false`.
198
+ *
199
+ * ## Why this lives here (objectui#10026)
200
+ *
201
+ * It used to be module-private in `@object-ui/components`' filter builder,
202
+ * which refused `2026-02-30` at the AUTHORING boundary, while this module — a
203
+ * package below it, unable to import it — ROLLED the same value into March 2nd
204
+ * on every display face. One concept, two answers. It moved down, exactly as
205
+ * `formatDate` itself moved into this package (see the header), so the filter
206
+ * builder and {@link toDisplayDate} now ask the one function. ⛔ A move, not a
207
+ * copy: `@object-ui/components` imports it from here.
208
+ *
209
+ * ## Why the engine cannot answer this on its own
210
+ *
211
+ * ECMAScript's date-string parse accepts a DAY of `01`-`31` for every month
212
+ * and rolls the surplus forward: `Date.parse('2026-02-30')` is March 2nd, not
213
+ * `NaN`. It rejects an out-of-range MONTH (`2026-13-01`), which is why a
214
+ * bad month was always a dash on the display path and a bad day never was.
215
+ * So the day is read back instead: build the date in UTC (no zone, so no DST
216
+ * gap can move it) and require all three parts to survive.
217
+ *
218
+ * `setUTCFullYear` rather than `Date.UTC(year, …)`: the latter maps years
219
+ * 0-99 onto 1900+y, so it answered `false` for `0026-08-01` — a real day,
220
+ * and one {@link toDisplayDate} renders (it undoes the same legacy mapping for
221
+ * the same reason). The move had to fix that, or refusing through this
222
+ * function would have dashed every year below 100.
223
+ */
224
+ export function isRealCalendarDate(dateOnly) {
225
+ if (!ISO_DATE_ONLY_RE.test(dateOnly))
226
+ return false;
227
+ const [year, month, day] = dateOnly.split('-').map(Number);
228
+ const probe = new Date(0);
229
+ probe.setUTCFullYear(year, month - 1, day);
230
+ return (probe.getUTCFullYear() === year &&
231
+ probe.getUTCMonth() === month - 1 &&
232
+ probe.getUTCDate() === day);
233
+ }
234
+ /**
235
+ * The ONE `value -> Date` step behind every function below.
236
+ *
237
+ * ## The defect (objectui#10110)
238
+ *
239
+ * ECMAScript parses the two ISO shapes into two different zones: a DATE-ONLY
240
+ * form is UTC, while a date-TIME form without an offset is local. So
241
+ * `new Date('2026-08-01')` is UTC midnight, and every getter this module then
242
+ * uses — `getFullYear`, `getMonth`, `getDate`, and `toLocaleDateString`'s own
243
+ * internal ones — reads it back in the VIEWER's zone. West of UTC that lands
244
+ * on the previous calendar day: `2026-08-01` rendered `Jul 31` for a UTC-7
245
+ * viewer while the stored value, the API response and a UTC+8 viewer all said
246
+ * August 1st. The relative branch shifted with it, one day per day
247
+ * (`2026-08-31` read `4 days ago` on the 3rd instead of `3 days ago`),
248
+ * because it compares two LOCAL start-of-days.
249
+ *
250
+ * ## The repair, and why it is not an offset
251
+ *
252
+ * A date-only value names a CALENDAR DAY and carries no instant, so there is
253
+ * no conversion to perform: this rebuilds it at LOCAL midnight of the day it
254
+ * names, and every local getter downstream then reports that same day in
255
+ * every zone. ⛔ Nothing here adds or subtracts hours. An offset that
256
+ * cancels the shift would be wrong again at the next DST boundary and wrong
257
+ * in the opposite direction for a viewer EAST of UTC, where the UTC-midnight
258
+ * parse already lands on the right day — the co-located suite drives both.
259
+ * `GridField`'s sub-grid cell already parsed its own date-only values this
260
+ * way before reaching `formatDate`; this is that treatment, moved to the one
261
+ * place every caller passes through (the sub-grid reads it from here since
262
+ * objectui#10301).
263
+ *
264
+ * A value with a time part is not rebuilt, in both spellings: it HAS an
265
+ * instant, and rendering an instant in the viewer's zone is the whole point
266
+ * of a `datetime`. The regex is what separates them, so the split is the
267
+ * VALUE's shape and never the field's declared type, which this module (pure,
268
+ * no schema) cannot see.
269
+ *
270
+ * ## What it refuses (objectui#10026)
271
+ *
272
+ * The engine's own parse still decides what is a date at all (`2026-13-01`
273
+ * is an Invalid Date, and so every face below renders `—`), with ONE
274
+ * addition: a date-only value naming a day its month does not have. The
275
+ * engine accepts `2026-02-30` and rolls it into March 2nd, so before this
276
+ * card every date face showed a real day nobody wrote, with nothing to say
277
+ * so. This step hands back an Invalid Date for it instead — the same answer
278
+ * the engine gives a bad month — so every caller renders the face it already
279
+ * renders for an unparsable value: `—` from the functions below, `EmptyValue`
280
+ * from the field carriers that read validity here, the raw stored string from
281
+ * a caller whose unparsable face is the raw string. ⛔ No new marker.
282
+ *
283
+ * The maintainer's ruling on objectui#10026 (option A) placed the refusal on
284
+ * the SHARED path, in this step and not in any one consumer: refusing in a
285
+ * single face would re-create the list-cell-versus-measure split
286
+ * objectui#4576 recorded. {@link isRealCalendarDate} is the one judgement,
287
+ * shared with the filter builder's authoring boundary.
288
+ *
289
+ * A value that carries a TIME is refused the same way when the day it is
290
+ * written on does not exist (objectui#10301): `2026-02-30T10:00:00Z` parses
291
+ * too, and rendered `Mar 2, 2026, 10:00 AM` on every datetime face. Triage
292
+ * graded that an inherited branch of the same ruling. The judgement reads the
293
+ * value's leading `YYYY-MM-DD` AS WRITTEN, never the day its instant lands on
294
+ * in some zone: a parsed `Date` always names a real day, so a check made after
295
+ * any conversion could only miss the refusal, and a real day written with an
296
+ * offset (`2026-02-28T23:30:00-05:00`, March 1st in UTC) is kept. A real
297
+ * date-time is otherwise untouched — no rebuild, the engine's instant.
298
+ *
299
+ * ## Why it is exported (objectui#10183)
300
+ *
301
+ * A caller that formats with its own `Intl` options — a face none of the
302
+ * functions below produce, such as the record summary chip's
303
+ * `dateStyle: 'medium'` — or that compares a value against "today" needs the
304
+ * `Date` itself, not a string. Such callers used to parse the value on their
305
+ * own (`new Date(value)`, or `Date.parse` and then a `Date` handed to
306
+ * `formatDate`, which this step then leaves alone), so the objectui#10110
307
+ * repair never reached them and each still read a date-only value one day
308
+ * early west of UTC. They take the `Date` from here instead.
309
+ *
310
+ * ⛔ Read the result with LOCAL getters or local-zone `Intl` formatting only.
311
+ * For a date-only value it is local midnight of the named day, so its
312
+ * `toISOString()` / UTC getters name the previous day east of UTC — hand
313
+ * those the stored value, never this. The faces in this module know such a
314
+ * `Date` when they are handed one and keep it out of the display zone
315
+ * (objectui#11693, see `calendarDays`).
316
+ */
317
+ export function toDisplayDate(value) {
318
+ const parsed = value instanceof Date ? value : new Date(value);
319
+ // The engine rolls a date-time written on a nonexistent day forward too;
320
+ // judge the day as written and refuse it (objectui#10301).
321
+ if (typeof value === 'string' && ISO_DATETIME_RE.test(value) && !isRealCalendarDate(value.slice(0, 10))) {
322
+ return new Date(NaN);
323
+ }
324
+ if (typeof value !== 'string' || !ISO_DATE_ONLY_RE.test(value) || isNaN(parsed.getTime())) {
325
+ return parsed;
326
+ }
327
+ // The engine rolled a nonexistent day forward; refuse it (objectui#10026).
328
+ if (!isRealCalendarDate(value))
329
+ return new Date(NaN);
330
+ const [year, month, day] = value.split('-').map(Number);
331
+ const local = new Date(year, month - 1, day);
332
+ // Years 0-99 only: the multi-argument constructor maps them onto 1900+y, so
333
+ // `0026-08-01` would render as 1926 where the string parse read year 26.
334
+ // Setting the year back is co-extensive with that legacy mapping and a
335
+ // no-op on every other year.
336
+ local.setFullYear(year);
337
+ calendarDays.add(local);
338
+ return local;
339
+ }
340
+ /**
341
+ * THE ONE READ of a stored END, where a bar or span is drawn to
342
+ * (objectui#11141): {@link toDisplayDate}'s read, and then, for a date-only
343
+ * value only, the next day's local midnight.
344
+ *
345
+ * ## The rule (objectui#11112's ruling, one home since objectui#11141)
346
+ *
347
+ * A date-only end is INCLUSIVE: `2024-01-01` to `2024-01-15` runs through
348
+ * January 15th, so the span is drawn to the start of the 16th, and a
349
+ * successor starting `2024-01-16` begins exactly where it ends. A span whose
350
+ * date-only start and end name the same day is one day long, across a DST
351
+ * change too: the step is a calendar day (`setDate`), never 24 hours, so it
352
+ * lands on the next local midnight, or on that day's first hour where its
353
+ * midnight does not exist. That is how a project plan and the schema
354
+ * catalog's gantt plans author an end.
355
+ *
356
+ * The result is the EXCLUSIVE end instant of a half-open span `[start, end)`,
357
+ * the form every gantt surface draws and schedules with. Date-only is decided
358
+ * by the value's own shape, with {@link isRealCalendarDate}, exactly as
359
+ * {@link toDisplayDate} decides it, so an end reads its day as a start does and
360
+ * then runs to that day's end. A value with a time part, a number or a `Date`
361
+ * is an instant: the span ends at it, and it is handed back as
362
+ * {@link toDisplayDate} hands it back.
363
+ *
364
+ * {@link toInclusiveEndDay} is the exact inverse: it names the day a span
365
+ * ending here runs through, so a read and a write of a date-only end agree.
366
+ *
367
+ * ⛔ One rule, one place: `plugin-timeline`'s gantt variant and
368
+ * `plugin-gantt` both read their ends here. A second copy of the step in a
369
+ * consumer is how the two gantt surfaces came to draw one plan a day apart.
370
+ * ⛔ Read the result with LOCAL getters only, as {@link toDisplayDate}'s is.
371
+ */
372
+ export function toDisplayEndDate(value) {
373
+ const end = toDisplayDate(value);
374
+ if (typeof value !== 'string' || !isRealCalendarDate(value))
375
+ return end;
376
+ end.setDate(end.getDate() + 1);
377
+ // Back to the start of that day. A day whose own midnight does not exist is
378
+ // read at its first hour, and the step keeps the hour, so without this the
379
+ // day after it would start an hour late and name itself back.
380
+ end.setHours(0, 0, 0, 0);
381
+ return end;
382
+ }
383
+ /**
384
+ * The day a span ending at `end` runs through: local midnight of the day that
385
+ * holds the last instant before `end` (objectui#11141).
386
+ *
387
+ * This is the exact inverse of {@link toDisplayEndDate} on a date-only end:
388
+ * the instant it reads `2024-01-15` as, local midnight of the 16th, names the
389
+ * 15th here, so a read, a drag and a write of a stored date-only end leave it
390
+ * the day it was, and a bar dragged to end at a day's local midnight names the
391
+ * day BEFORE it. An end inside a day (a `datetime` end, or a shift band's
392
+ * edge) names that day, the last one the span reaches into.
393
+ *
394
+ * It names a day for display and for a date-only write. ⛔ It is not a read of
395
+ * a stored value, and a `datetime` end still keeps its own instant: hand this
396
+ * only to a face that names a day, or to a field that stores one. Read the
397
+ * result with LOCAL getters only; an Invalid Date stays invalid.
398
+ */
399
+ export function toInclusiveEndDay(end) {
400
+ const day = new Date(end.getTime() - 1);
401
+ day.setHours(0, 0, 0, 0);
402
+ calendarDays.add(day);
403
+ return day;
404
+ }
405
+ /**
406
+ * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天",
407
+ * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase.
408
+ */
409
+ function formatRelativeDays(diffDays, locale) {
410
+ try {
411
+ const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day');
412
+ return phrase.charAt(0).toUpperCase() + phrase.slice(1);
413
+ }
414
+ catch {
415
+ // Invalid locale tag — degrade to English rather than crash the cell.
416
+ if (diffDays === 0)
417
+ return 'Today';
418
+ if (diffDays === 1)
419
+ return 'Tomorrow';
420
+ if (diffDays === -1)
421
+ return 'Yesterday';
422
+ return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`;
423
+ }
424
+ }
425
+ /**
426
+ * The options bag {@link formatRelativeDate} hands to {@link formatDate} for
427
+ * its out-of-window ABSOLUTE fallback, with `style` neutralised.
428
+ *
429
+ * Two reasons, both load-bearing since objectui#7745 made `formatDate` read
430
+ * `options.style`:
431
+ *
432
+ * 1. **Behaviour preservation.** `formatRelativeDate` does not read
433
+ * `options.style`, and #7745 does not change that. Without the strip it
434
+ * would start reading it THROUGH this delegation for dates outside the
435
+ * ±7-day window only — a face change on a live path (grid cell, gantt
436
+ * tooltip) that no card authorises.
437
+ * 2. **Termination.** `formatDate` resolves `'relative'` by calling
438
+ * `formatRelativeDate`, which lands back here. With the style still in
439
+ * the bag, `formatRelativeDate(v, { style: 'relative' })` on an
440
+ * out-of-window date would recurse until the stack ran out.
441
+ *
442
+ * The bag is returned UNCHANGED when there is nothing to strip, so the common
443
+ * path allocates nothing.
444
+ */
445
+ function absoluteFallbackOptions(options) {
446
+ if (options === undefined || options.style === undefined)
447
+ return options;
448
+ return { ...options, style: undefined };
449
+ }
450
+ /**
451
+ * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
452
+ * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
453
+ *
454
+ * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
455
+ * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
456
+ * past dates render as plain "N days ago" instead. The overdue phrase has no
457
+ * `Intl` equivalent, so it resolves through `options.t` (key
458
+ * `fields.relativeDate.overdue`) with an English fallback.
459
+ *
460
+ * Days are counted in the display zone (objectui#11693): "today" is the
461
+ * display zone's today, and an instant falls on its day there. A date-only
462
+ * value keeps the day it names and is counted from that same today, so an
463
+ * instant and a date-only value on one day read one phrase.
464
+ */
465
+ export function formatRelativeDate(value, options) {
466
+ if (value === null || value === undefined || value === '')
467
+ return '—';
468
+ const date = toDisplayDate(value);
469
+ if (!(date instanceof Date) || isNaN(date.getTime()))
470
+ return '—';
471
+ const diffDays = dayNumber(dayIn(date, zoneFor(date))) - dayNumber(dayIn(new Date(), displayTimeZone));
472
+ // Beyond the ±7-day window, fall back to the absolute (already localized) form.
473
+ if (diffDays < -7 || diffDays > 7)
474
+ return formatDate(date, undefined, absoluteFallbackOptions(options));
475
+ if (diffDays < -1 && options?.dueLike) {
476
+ const absDays = Math.abs(diffDays);
477
+ const key = 'fields.relativeDate.overdue';
478
+ const translated = options.t?.(key, { count: absDays });
479
+ return translated && translated !== key ? translated : `Overdue ${absDays}d`;
480
+ }
481
+ return formatRelativeDays(diffDays, options?.locale);
482
+ }
483
+ /**
484
+ * Format date value.
485
+ *
486
+ * The named face comes from EITHER spelling — the positional `style`
487
+ * parameter or `options.style` — and the precedence between them is pinned
488
+ * (objectui#7745):
489
+ *
490
+ * > **The positional argument wins. `options.style` is consulted only when the
491
+ * > positional slot is `undefined`.**
492
+ *
493
+ * Before #7745 only the positional spelling was read here, while
494
+ * {@link formatDateTime} read only the `options` one — one shared bag, two
495
+ * spellings for one concept, and on THIS function the options spelling did
496
+ * nothing at all: `formatDate(v, undefined, { style: 'short' })` silently
497
+ * rendered the default face with no diagnostic. Reading the key is the
498
+ * additive half of the maintainer's long-run ruling on objectui#7443.
499
+ *
500
+ * Why the positional wins, and not the newer key:
501
+ *
502
+ * - It is the ONLY direction that is purely additive. It fires exactly on
503
+ * the input that is a silent no-op today (positional absent, key present);
504
+ * every call that renders a face today renders the same face after.
505
+ * - The bag is SHARED across three functions, so it can legitimately carry a
506
+ * key meant for a sibling — that is this module's convention (`dueLike`
507
+ * and `t` are read by `formatRelativeDate` alone). A caller that built
508
+ * `{ style: 'compact', locale }` for `formatDateTime` and reused the bag
509
+ * for `formatDate(v, 'short', bag)` must keep its short face. A key aimed
510
+ * at a sibling function must not outrank an argument written for THIS
511
+ * call — that is objectui#7694's shape (an alias overwriting the canonical
512
+ * key), and the silent-override half of objectui#4272.
513
+ *
514
+ * `??`, not `||`, is what "absent" means here: `formatDate(v, '', bag)`
515
+ * renders the default face today and must keep doing so, so an empty string
516
+ * counts as GIVEN and does not fall through to the key.
517
+ */
518
+ export function formatDate(value, style, options) {
519
+ if (value === null || value === undefined || value === '')
520
+ return '—';
521
+ const date = toDisplayDate(value);
522
+ if (!(date instanceof Date) || isNaN(date.getTime()))
523
+ return '—';
524
+ const effectiveStyle = style ?? options?.style;
525
+ // An instant names its day in the display zone; a calendar day is its own
526
+ // day in every zone (objectui#11693).
527
+ const zone = zoneFor(date);
528
+ if (effectiveStyle === 'short') {
529
+ // Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
530
+ // Only the MONTH token is localized: the surrounding compact shape (day,
531
+ // apostrophe + 2-digit year) is a deliberate fixed layout for narrow
532
+ // cards, not a locale-derived one. The tag comes from `options.locale`
533
+ // like the default branch below — hardcoding `'en-US'` here made this the
534
+ // one branch that ignored a locale its caller had threaded (objectui#4272).
535
+ const month = date.toLocaleDateString(options?.locale, { month: 'short', ...inZone(zone) });
536
+ const { day, year } = dayIn(date, zone);
537
+ return `${month} ${day}, '${String(year).slice(-2)}`;
538
+ }
539
+ if (effectiveStyle === 'relative') {
540
+ return formatRelativeDate(date, options);
541
+ }
542
+ // Default format: locale-aware human-readable. Drop the year when it
543
+ // matches the current year — Salesforce / HubSpot / Linear all do this
544
+ // because the year is rarely useful for in-progress records and the
545
+ // verbose "2026年7月21日" form crowds cards and table cells. Past- /
546
+ // future-year dates keep the year so users can disambiguate.
547
+ const isCurrentYear = dayIn(date, zone).year === dayIn(new Date(), displayTimeZone).year;
548
+ return date.toLocaleDateString(options?.locale, {
549
+ year: isCurrentYear ? undefined : 'numeric',
550
+ month: 'short',
551
+ day: 'numeric',
552
+ ...inZone(zone),
553
+ });
554
+ }
555
+ /**
556
+ * The `'compact'` datetime face as the two halves a grid cell paints
557
+ * separately — `7/4/2024` and `7:00 am` for `2024-07-04T07:00:00Z` in `en-US`.
558
+ *
559
+ * `formatDateTime(value, { style: 'compact', ...options })` is exactly
560
+ * `date + ' ' + time` of what this returns, so a caller that wants the face
561
+ * as ONE string and a caller that wants to style the halves differently
562
+ * cannot drift apart. That drift is what objectui#7443 recorded:
563
+ * `DateTimeCellRenderer` inlined these two option bags and never called this
564
+ * module, so `datetime` had two display conventions while `date` had one —
565
+ * the same shape as objectui#4576, which this repo has already paid for once.
566
+ *
567
+ * `null` for a value this module renders as `'—'`; the cell renders its own
568
+ * empty state for those, so it never sees the dash.
569
+ */
570
+ export function formatDateTimeCompactParts(value, options) {
571
+ if (value === null || value === undefined || value === '')
572
+ return null;
573
+ const date = toDisplayDate(value);
574
+ if (!(date instanceof Date) || isNaN(date.getTime()))
575
+ return null;
576
+ // Both halves in the display zone, so the day and the time of one instant
577
+ // never come from two zones (objectui#11693).
578
+ const zone = zoneFor(date);
579
+ return {
580
+ date: date.toLocaleDateString(options?.locale, {
581
+ month: 'numeric',
582
+ day: 'numeric',
583
+ year: 'numeric',
584
+ ...inZone(zone),
585
+ }),
586
+ // `hour12` stays declared: this is the compact Airtable-style cell, and
587
+ // the 12-hour face is its design, not a locale artefact. Locales that
588
+ // write no am/pm marker simply ignore it.
589
+ time: date.toLocaleTimeString(options?.locale, {
590
+ hour: 'numeric',
591
+ minute: '2-digit',
592
+ hour12: true,
593
+ ...inZone(zone),
594
+ }).toLowerCase(),
595
+ };
596
+ }
597
+ /**
598
+ * Format datetime value.
599
+ *
600
+ * `options.style` selects a named face:
601
+ *
602
+ * - `'compact'` — the dense grid face, `7/4/2024 7:00 am` in `en-US`. It is
603
+ * what every `datetime` CELL renders, and what `DateTimeCellRenderer`
604
+ * used to build from its own inlined `Intl` bags (objectui#7443).
605
+ * - anything else, including absent — the verbose default,
606
+ * `Jul 4, 2024, 07:00 AM` in `en-US`. Unchanged, and still what a
607
+ * non-cell caller (dataset measure, gantt tooltip, data-table) gets.
608
+ *
609
+ * The signature is `(value, options?)`, unchanged: `style` is a key of
610
+ * `options`, not a positional parameter, so every existing call —
611
+ * `formatDateTime(v, { locale })` included — keeps meaning exactly what it
612
+ * meant (see the note on `DateDisplayOptions.style` for why the positional
613
+ * shape `formatDate` uses was refused here).
614
+ *
615
+ * `options` is optional, so a caller that passes nothing keeps the exact
616
+ * runtime-default behavior it had. Before objectui#4272 the parameter did not
617
+ * exist at all, which meant no caller could localize this function however
618
+ * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the
619
+ * MACHINE's locale, which is neither of the repo's two locale channels.
620
+ * Callers should pass the tag from `useDisplayLocale()`.
621
+ *
622
+ * The zone is NOT an option: an instant renders in the display zone the host
623
+ * declared with {@link setDisplayTimeZone}, else the viewer's, on every face
624
+ * this module produces (objectui#11693).
625
+ */
626
+ export function formatDateTime(value, options) {
627
+ if (value === null || value === undefined || value === '')
628
+ return '—';
629
+ const date = toDisplayDate(value);
630
+ if (!(date instanceof Date) || isNaN(date.getTime()))
631
+ return '—';
632
+ if (options?.style === 'compact') {
633
+ const parts = formatDateTimeCompactParts(date, options);
634
+ return parts ? `${parts.date} ${parts.time}` : '—';
635
+ }
636
+ return date.toLocaleDateString(options?.locale, {
637
+ year: 'numeric',
638
+ month: 'short',
639
+ day: 'numeric',
640
+ hour: '2-digit',
641
+ minute: '2-digit',
642
+ ...inZone(zoneFor(date)),
643
+ });
644
+ }
@@ -182,7 +182,14 @@ export function resolveDateMacros(filter, now = new Date()) {
182
182
  }
183
183
  if (Array.isArray(value))
184
184
  return value.map(walk);
185
+ // Only a PLAIN object (prototype `Object.prototype` or `null`) is walked;
186
+ // any other object — a `Date` comparand above all — is returned as the
187
+ // same instance rather than rebuilt from its own keys into `{}`
188
+ // (objectui#10506).
185
189
  if (typeof value === 'object') {
190
+ const proto = Object.getPrototypeOf(value);
191
+ if (proto !== Object.prototype && proto !== null)
192
+ return value;
186
193
  const out = {};
187
194
  for (const k of Object.keys(value))
188
195
  out[k] = walk(value[k]);