@dowel-ui/react 0.2.0 → 0.4.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 (141) hide show
  1. package/README.md +14 -7
  2. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
  3. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
  4. package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
  5. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
  6. package/dist/components/ai-agent-plan/index.d.ts +2 -0
  7. package/dist/components/ai-agent-plan/index.js +2 -0
  8. package/dist/components/ai-agent-plan/meta.js +17 -0
  9. package/dist/components/ai-agent-plan/meta.js.map +1 -0
  10. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  11. package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
  12. package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
  13. package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
  14. package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
  15. package/dist/components/ai-approval-request/index.d.ts +2 -0
  16. package/dist/components/ai-approval-request/index.js +2 -0
  17. package/dist/components/ai-approval-request/meta.js +17 -0
  18. package/dist/components/ai-approval-request/meta.js.map +1 -0
  19. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  20. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  21. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  22. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  23. package/dist/components/ai-disclosure/index.d.ts +2 -0
  24. package/dist/components/ai-disclosure/index.js +2 -0
  25. package/dist/components/ai-disclosure/meta.js +17 -0
  26. package/dist/components/ai-disclosure/meta.js.map +1 -0
  27. package/dist/components/ai-response/ai-response.d.ts.map +1 -1
  28. package/dist/components/ai-response/ai-response.js +1 -0
  29. package/dist/components/ai-response/ai-response.js.map +1 -1
  30. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
  31. package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
  32. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
  33. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  34. package/dist/components/alert/alert.d.ts +1 -1
  35. package/dist/components/avatar/avatar.d.ts +1 -1
  36. package/dist/components/badge/badge.d.ts +1 -1
  37. package/dist/components/button/button.d.ts +1 -1
  38. package/dist/components/date-picker/date-picker.js +1 -1
  39. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  40. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  41. package/dist/components/diff-viewer/diff-model.js +180 -0
  42. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  43. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  44. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  45. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  46. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  47. package/dist/components/diff-viewer/index.d.ts +3 -0
  48. package/dist/components/diff-viewer/index.js +3 -0
  49. package/dist/components/diff-viewer/meta.js +17 -0
  50. package/dist/components/diff-viewer/meta.js.map +1 -0
  51. package/dist/components/file-upload/file-upload.d.ts +46 -0
  52. package/dist/components/file-upload/file-upload.d.ts.map +1 -0
  53. package/dist/components/file-upload/file-upload.js +204 -0
  54. package/dist/components/file-upload/file-upload.js.map +1 -0
  55. package/dist/components/file-upload/index.d.ts +3 -0
  56. package/dist/components/file-upload/index.js +3 -0
  57. package/dist/components/file-upload/meta.js +17 -0
  58. package/dist/components/file-upload/meta.js.map +1 -0
  59. package/dist/components/file-upload/upload-queue.d.ts +87 -0
  60. package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
  61. package/dist/components/file-upload/upload-queue.js +249 -0
  62. package/dist/components/file-upload/upload-queue.js.map +1 -0
  63. package/dist/components/log-viewer/index.d.ts +3 -0
  64. package/dist/components/log-viewer/index.js +3 -0
  65. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  66. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  67. package/dist/components/log-viewer/log-stream.js +120 -0
  68. package/dist/components/log-viewer/log-stream.js.map +1 -0
  69. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  70. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  71. package/dist/components/log-viewer/log-viewer.js +284 -0
  72. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  73. package/dist/components/log-viewer/meta.js +17 -0
  74. package/dist/components/log-viewer/meta.js.map +1 -0
  75. package/dist/components/progress/progress.d.ts +1 -1
  76. package/dist/components/progress/progress.js +1 -0
  77. package/dist/components/progress/progress.js.map +1 -1
  78. package/dist/components/spinner/spinner.d.ts +1 -1
  79. package/dist/components/spinner/spinner.js +1 -0
  80. package/dist/components/spinner/spinner.js.map +1 -1
  81. package/dist/components/tags-input/index.d.ts +2 -0
  82. package/dist/components/tags-input/index.js +2 -0
  83. package/dist/components/tags-input/meta.js +17 -0
  84. package/dist/components/tags-input/meta.js.map +1 -0
  85. package/dist/components/tags-input/tags-input.d.ts +47 -0
  86. package/dist/components/tags-input/tags-input.d.ts.map +1 -0
  87. package/dist/components/tags-input/tags-input.js +182 -0
  88. package/dist/components/tags-input/tags-input.js.map +1 -0
  89. package/dist/components/time-range-picker/index.d.ts +3 -0
  90. package/dist/components/time-range-picker/index.js +3 -0
  91. package/dist/components/time-range-picker/meta.js +22 -0
  92. package/dist/components/time-range-picker/meta.js.map +1 -0
  93. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  94. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  95. package/dist/components/time-range-picker/time-expression.js +335 -0
  96. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  97. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  98. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  99. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  100. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  101. package/dist/components/toast/toast.d.ts +1 -1
  102. package/dist/index.d.ts +21 -1
  103. package/dist/index.js +14 -2
  104. package/dist/registry/components.d.ts.map +1 -1
  105. package/dist/registry/components.js +66 -50
  106. package/dist/registry/components.js.map +1 -1
  107. package/package.json +6 -4
  108. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  109. package/src/components/ai-agent-plan/index.ts +10 -0
  110. package/src/components/ai-agent-plan/meta.ts +19 -0
  111. package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
  112. package/src/components/ai-approval-request/index.ts +7 -0
  113. package/src/components/ai-approval-request/meta.ts +21 -0
  114. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  115. package/src/components/ai-disclosure/index.ts +9 -0
  116. package/src/components/ai-disclosure/meta.ts +23 -0
  117. package/src/components/ai-response/ai-response.tsx +1 -0
  118. package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
  119. package/src/components/diff-viewer/diff-model.ts +256 -0
  120. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  121. package/src/components/diff-viewer/index.ts +17 -0
  122. package/src/components/diff-viewer/meta.ts +20 -0
  123. package/src/components/file-upload/file-upload.tsx +322 -0
  124. package/src/components/file-upload/index.ts +20 -0
  125. package/src/components/file-upload/meta.ts +21 -0
  126. package/src/components/file-upload/upload-queue.ts +337 -0
  127. package/src/components/log-viewer/index.ts +21 -0
  128. package/src/components/log-viewer/log-stream.ts +176 -0
  129. package/src/components/log-viewer/log-viewer.tsx +424 -0
  130. package/src/components/log-viewer/meta.ts +21 -0
  131. package/src/components/progress/progress.tsx +3 -0
  132. package/src/components/spinner/spinner.tsx +5 -0
  133. package/src/components/tags-input/index.ts +1 -0
  134. package/src/components/tags-input/meta.ts +23 -0
  135. package/src/components/tags-input/tags-input.tsx +300 -0
  136. package/src/components/time-range-picker/index.ts +25 -0
  137. package/src/components/time-range-picker/meta.ts +22 -0
  138. package/src/components/time-range-picker/time-expression.ts +491 -0
  139. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  140. package/src/index.ts +8 -0
  141. package/src/registry/components.ts +16 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Time ranges as expressions, not as two frozen timestamps.
3
+ *
4
+ * `now-6h` is still the last six hours tomorrow; a resolved pair is six hours
5
+ * of last Tuesday forever. Storing the expression is what lets a dashboard URL
6
+ * survive being bookmarked and reloaded.
7
+ *
8
+ * The grammar is a deliberate subset of the one Grafana, Datadog and Kibana
9
+ * converged on, so the strings are already familiar:
10
+ *
11
+ * range := expr ".." expr
12
+ * expr := "now" offset* snap? | ISO-8601
13
+ * offset := ("-" | "+") digits unit
14
+ * snap := "/" unit
15
+ * unit := s | m | h | d | w | M | y
16
+ *
17
+ * now-6h..now the last six hours
18
+ * now-6h/h..now the last six hours, starting on the hour
19
+ * now/d..now/d today, all of it
20
+ * now-1d/d..now-1d/d yesterday
21
+ * 2026-01-01..now since a fixed instant
22
+ *
23
+ * Everything here is pure and takes `now` as an argument: reading the clock
24
+ * during render would disagree between the server and the browser.
25
+ */
26
+
27
+ export type TimeUnit = "s" | "m" | "h" | "d" | "w" | "M" | "y";
28
+
29
+ export interface ResolveOptions {
30
+ /** The instant `now` refers to. Required, so nothing here reads the clock. */
31
+ now: Date;
32
+ /**
33
+ * IANA zone the snapping happens in — "start of day" is a different instant
34
+ * in Auckland than in Denver. Defaults to the runtime zone.
35
+ */
36
+ timeZone?: string;
37
+ }
38
+
39
+ export interface ResolvedRange {
40
+ from: Date;
41
+ to: Date;
42
+ }
43
+
44
+ /** Why an expression could not be resolved. Shown to the user verbatim. */
45
+ export class TimeExpressionError extends Error {
46
+ constructor(
47
+ message: string,
48
+ /** The part of the input at fault, for pointing at it. */
49
+ readonly input: string,
50
+ ) {
51
+ super(message);
52
+ this.name = "TimeExpressionError";
53
+ }
54
+ }
55
+
56
+ const UNIT_NAMES: Record<TimeUnit, string> = {
57
+ s: "second",
58
+ m: "minute",
59
+ h: "hour",
60
+ d: "day",
61
+ w: "week",
62
+ M: "month",
63
+ y: "year",
64
+ };
65
+
66
+ const UNITS = new Set(Object.keys(UNIT_NAMES) as TimeUnit[]);
67
+
68
+ /* ------------------------------------------------------------------ */
69
+ /* Zone arithmetic */
70
+ /* ------------------------------------------------------------------ */
71
+
72
+ interface Parts {
73
+ year: number;
74
+ month: number;
75
+ day: number;
76
+ hour: number;
77
+ minute: number;
78
+ second: number;
79
+ ms: number;
80
+ }
81
+
82
+ const PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
83
+
84
+ function formatterFor(timeZone: string): Intl.DateTimeFormat {
85
+ let formatter = PART_FORMATTERS.get(timeZone);
86
+ if (!formatter) {
87
+ formatter = new Intl.DateTimeFormat("en-US", {
88
+ timeZone,
89
+ hour12: false,
90
+ year: "numeric",
91
+ month: "2-digit",
92
+ day: "2-digit",
93
+ hour: "2-digit",
94
+ minute: "2-digit",
95
+ second: "2-digit",
96
+ });
97
+ PART_FORMATTERS.set(timeZone, formatter);
98
+ }
99
+ return formatter;
100
+ }
101
+
102
+ /** Wall-clock fields of an instant, as read in `timeZone`. */
103
+ function toParts(instant: Date, timeZone?: string): Parts {
104
+ if (!timeZone) {
105
+ return {
106
+ year: instant.getFullYear(),
107
+ month: instant.getMonth() + 1,
108
+ day: instant.getDate(),
109
+ hour: instant.getHours(),
110
+ minute: instant.getMinutes(),
111
+ second: instant.getSeconds(),
112
+ ms: instant.getMilliseconds(),
113
+ };
114
+ }
115
+
116
+ const found: Record<string, number> = {};
117
+ for (const part of formatterFor(timeZone).formatToParts(instant)) {
118
+ if (part.type !== "literal") found[part.type] = Number(part.value);
119
+ }
120
+ return {
121
+ year: found.year ?? 0,
122
+ month: found.month ?? 1,
123
+ day: found.day ?? 1,
124
+ // Midnight formats as hour 24 rather than 0 in some engines.
125
+ hour: (found.hour ?? 0) % 24,
126
+ minute: found.minute ?? 0,
127
+ second: found.second ?? 0,
128
+ ms: instant.getMilliseconds(),
129
+ };
130
+ }
131
+
132
+ function asUtc(parts: Parts): number {
133
+ return Date.UTC(
134
+ parts.year,
135
+ parts.month - 1,
136
+ parts.day,
137
+ parts.hour,
138
+ parts.minute,
139
+ parts.second,
140
+ parts.ms,
141
+ );
142
+ }
143
+
144
+ /**
145
+ * The instant at which `timeZone` reads these wall-clock fields.
146
+ *
147
+ * Two passes: guess the fields as UTC, measure the offset there, correct, and
148
+ * measure again in case the correction crossed a transition. A wall-clock time
149
+ * at a DST boundary can be ambiguous or absent; this picks one rather than
150
+ * throwing — an hour's difference once a year is a fair trade for not shipping
151
+ * a timezone database.
152
+ */
153
+ function fromParts(parts: Parts, timeZone?: string): Date {
154
+ if (!timeZone) {
155
+ return new Date(
156
+ parts.year,
157
+ parts.month - 1,
158
+ parts.day,
159
+ parts.hour,
160
+ parts.minute,
161
+ parts.second,
162
+ parts.ms,
163
+ );
164
+ }
165
+
166
+ const wall = asUtc(parts);
167
+ let instant = new Date(wall);
168
+ for (let pass = 0; pass < 2; pass += 1) {
169
+ const offset = asUtc(toParts(instant, timeZone)) - instant.getTime();
170
+ const next = new Date(wall - offset);
171
+ if (next.getTime() === instant.getTime()) break;
172
+ instant = next;
173
+ }
174
+ return instant;
175
+ }
176
+
177
+ /* ------------------------------------------------------------------ */
178
+ /* Offsets and snapping */
179
+ /* ------------------------------------------------------------------ */
180
+
181
+ /** `month` is 1-based, matching `Parts`. */
182
+ function daysInMonth(year: number, month: number): number {
183
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
184
+ }
185
+
186
+ const FIXED_MS: Partial<Record<TimeUnit, number>> = {
187
+ s: 1000,
188
+ m: 60_000,
189
+ h: 3_600_000,
190
+ };
191
+
192
+ /**
193
+ * Applies `±N unit` to an instant.
194
+ *
195
+ * Seconds, minutes and hours are fixed durations. Days, weeks, months and years
196
+ * are calendar arithmetic: `now-1d` means the same wall-clock time yesterday,
197
+ * which on the day a zone changes offset is 23 or 25 hours, not 24. And
198
+ * `now-1M` from the 31st lands on the last day of a shorter month rather than
199
+ * overflowing into the next one.
200
+ */
201
+ function applyOffset(instant: Date, amount: number, unit: TimeUnit, timeZone?: string): Date {
202
+ const fixed = FIXED_MS[unit];
203
+ if (fixed !== undefined) return new Date(instant.getTime() + amount * fixed);
204
+
205
+ const parts = { ...toParts(instant, timeZone) };
206
+
207
+ if (unit === "d") {
208
+ parts.day += amount;
209
+ } else if (unit === "w") {
210
+ parts.day += amount * 7;
211
+ } else {
212
+ if (unit === "y") {
213
+ parts.year += amount;
214
+ } else {
215
+ const target = parts.month - 1 + amount;
216
+ parts.year += Math.floor(target / 12);
217
+ parts.month = (((target % 12) + 12) % 12) + 1;
218
+ }
219
+ // Clamp instead of overflowing: 31 January minus one month is the end of
220
+ // February, not the third of March, and a year back from 29 February is
221
+ // the 28th rather than 1 March.
222
+ parts.day = Math.min(parts.day, daysInMonth(parts.year, parts.month));
223
+ }
224
+
225
+ return fromParts(parts, timeZone);
226
+ }
227
+
228
+ /**
229
+ * Rounds an instant to a unit boundary, `edge` deciding which one.
230
+ *
231
+ * The detail that catches every reimplementation: `now/d..now/d` means *all of
232
+ * today*, so the start floors to midnight and the end climbs to that day's last
233
+ * millisecond. Flooring both yields a zero-length range and an empty chart.
234
+ */
235
+ function applySnap(
236
+ instant: Date,
237
+ unit: TimeUnit,
238
+ edge: "start" | "end",
239
+ timeZone?: string,
240
+ ): Date {
241
+ const parts = { ...toParts(instant, timeZone) };
242
+
243
+ parts.ms = 0;
244
+ if (unit !== "s") parts.second = 0;
245
+ if (unit !== "s" && unit !== "m") parts.minute = 0;
246
+ if (unit === "d" || unit === "w" || unit === "M" || unit === "y") parts.hour = 0;
247
+ if (unit === "M" || unit === "y") parts.day = 1;
248
+ if (unit === "y") parts.month = 1;
249
+
250
+ if (unit === "w") {
251
+ // ISO weeks: Monday starts the week. Locale-varying week starts are a
252
+ // separate decision, and one an app should make once rather than per range.
253
+ //
254
+ // The weekday is read off the calendar date itself, not off the instant —
255
+ // asking a `Date` built from local fields for `getDay()` would answer for
256
+ // the runtime's zone rather than the one being snapped in.
257
+ const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
258
+ parts.day -= (weekday + 6) % 7;
259
+ }
260
+
261
+ const start = fromParts(parts, timeZone);
262
+ if (edge === "start") return start;
263
+
264
+ // The end of the unit is one millisecond before the next one begins, which
265
+ // keeps `to` inclusive without an off-by-one at every boundary.
266
+ const nextStart = applyOffset(start, 1, unit, timeZone);
267
+ return new Date(nextStart.getTime() - 1);
268
+ }
269
+
270
+ /* ------------------------------------------------------------------ */
271
+ /* Parsing */
272
+ /* ------------------------------------------------------------------ */
273
+
274
+ const ISO =
275
+ /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
276
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
277
+ const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})/;
278
+ const OFFSET = /^([+-])(\d+)([smhdwMy])/;
279
+
280
+ /** Resolves a single side of a range. Exported for the input's live feedback. */
281
+ export function resolveExpression(
282
+ expression: string,
283
+ edge: "start" | "end",
284
+ options: ResolveOptions,
285
+ ): Date {
286
+ const input = expression.trim();
287
+ if (input === "") throw new TimeExpressionError("Enter a time.", input);
288
+
289
+ if (ISO.test(input)) {
290
+ // `new Date` rolls an impossible day forward rather than refusing it —
291
+ // 30 February parses happily as 2 March — so the calendar date is checked
292
+ // against the month before the string is trusted.
293
+ const calendar = CALENDAR_DATE.exec(input);
294
+ if (
295
+ calendar &&
296
+ (Number(calendar[2]) < 1 ||
297
+ Number(calendar[2]) > 12 ||
298
+ Number(calendar[3]) < 1 ||
299
+ Number(calendar[3]) > daysInMonth(Number(calendar[1]), Number(calendar[2])))
300
+ ) {
301
+ throw new TimeExpressionError(`${input} is not a real date.`, input);
302
+ }
303
+
304
+ const dateOnly = DATE_ONLY.exec(input);
305
+ if (dateOnly) {
306
+ // A bare date means a day in the reader's zone, not midnight UTC, and on
307
+ // the closing side it means the whole of that day. `2026-01-01..2026-01-31`
308
+ // that stops at the 31st's first millisecond silently drops a day of data.
309
+ const parts: Parts = {
310
+ year: Number(dateOnly[1]),
311
+ month: Number(dateOnly[2]),
312
+ day: Number(dateOnly[3]),
313
+ hour: 0,
314
+ minute: 0,
315
+ second: 0,
316
+ ms: 0,
317
+ };
318
+ const start = fromParts(parts, options.timeZone);
319
+ if (Number.isNaN(start.getTime())) {
320
+ throw new TimeExpressionError(`${input} is not a real date.`, input);
321
+ }
322
+ return edge === "start" ? start : applySnap(start, "d", "end", options.timeZone);
323
+ }
324
+
325
+ const parsed = new Date(input);
326
+ if (Number.isNaN(parsed.getTime())) {
327
+ throw new TimeExpressionError(`${input} is not a real date.`, input);
328
+ }
329
+ return parsed;
330
+ }
331
+
332
+ if (!input.startsWith("now")) {
333
+ throw new TimeExpressionError(
334
+ `Expected "now" or a date like 2026-01-31, not "${input}".`,
335
+ input,
336
+ );
337
+ }
338
+
339
+ let rest = input.slice(3);
340
+ let instant = options.now;
341
+
342
+ while (rest.length > 0 && rest[0] !== "/") {
343
+ const match = OFFSET.exec(rest);
344
+ if (!match) {
345
+ throw new TimeExpressionError(`Expected an offset like -6h, not "${rest}".`, rest);
346
+ }
347
+ const [whole, sign, digits, unit] = match;
348
+ const amount = Number(digits) * (sign === "-" ? -1 : 1);
349
+ instant = applyOffset(instant, amount, unit as TimeUnit, options.timeZone);
350
+ rest = rest.slice(whole.length);
351
+ }
352
+
353
+ if (rest.startsWith("/")) {
354
+ const unit = rest.slice(1);
355
+ if (!UNITS.has(unit as TimeUnit)) {
356
+ throw new TimeExpressionError(
357
+ `"${unit || "/"}" is not a unit. Use s, m, h, d, w, M or y.`,
358
+ rest,
359
+ );
360
+ }
361
+ instant = applySnap(instant, unit as TimeUnit, edge, options.timeZone);
362
+ }
363
+
364
+ return instant;
365
+ }
366
+
367
+ /**
368
+ * Turns a range expression into the two instants a query needs.
369
+ *
370
+ * Throws `TimeExpressionError` rather than returning a fallback range: a chart
371
+ * silently showing the wrong window is worse than one that says it cannot.
372
+ */
373
+ export function resolveTimeRange(expression: string, options: ResolveOptions): ResolvedRange {
374
+ const separator = expression.indexOf("..");
375
+ if (separator === -1) {
376
+ throw new TimeExpressionError(
377
+ `Expected two times separated by "..", as in now-6h..now.`,
378
+ expression,
379
+ );
380
+ }
381
+
382
+ const from = resolveExpression(expression.slice(0, separator), "start", options);
383
+ const to = resolveExpression(expression.slice(separator + 2), "end", options);
384
+
385
+ if (from.getTime() > to.getTime()) {
386
+ throw new TimeExpressionError("The range ends before it starts.", expression);
387
+ }
388
+
389
+ return { from, to };
390
+ }
391
+
392
+ /** Whether an expression resolves, without the caller writing a try/catch. */
393
+ export function isValidTimeRange(expression: string, options: ResolveOptions): boolean {
394
+ try {
395
+ resolveTimeRange(expression, options);
396
+ return true;
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+
402
+ /* ------------------------------------------------------------------ */
403
+ /* Presets and labelling */
404
+ /* ------------------------------------------------------------------ */
405
+
406
+ export interface TimeRangePreset {
407
+ expression: string;
408
+ label: string;
409
+ /** Groups presets under a heading in the picker. */
410
+ group: string;
411
+ }
412
+
413
+ /** Relative windows first, for debugging; calendar periods after, for reporting. */
414
+ export const DEFAULT_PRESETS: TimeRangePreset[] = [
415
+ { expression: "now-5m..now", label: "Last 5 minutes", group: "Relative" },
416
+ { expression: "now-15m..now", label: "Last 15 minutes", group: "Relative" },
417
+ { expression: "now-1h..now", label: "Last hour", group: "Relative" },
418
+ { expression: "now-6h..now", label: "Last 6 hours", group: "Relative" },
419
+ { expression: "now-24h..now", label: "Last 24 hours", group: "Relative" },
420
+ { expression: "now-7d..now", label: "Last 7 days", group: "Relative" },
421
+ { expression: "now-30d..now", label: "Last 30 days", group: "Relative" },
422
+ { expression: "now/d..now/d", label: "Today", group: "Calendar" },
423
+ { expression: "now-1d/d..now-1d/d", label: "Yesterday", group: "Calendar" },
424
+ { expression: "now/w..now/w", label: "This week", group: "Calendar" },
425
+ { expression: "now-1w/w..now-1w/w", label: "Last week", group: "Calendar" },
426
+ { expression: "now/M..now/M", label: "This month", group: "Calendar" },
427
+ { expression: "now-1M/M..now-1M/M", label: "Last month", group: "Calendar" },
428
+ ];
429
+
430
+ const RELATIVE = /^now-(\d+)([smhdwMy])(?:\/[smhdwMy])?\.\.now$/;
431
+
432
+ /**
433
+ * A sentence for the trigger.
434
+ *
435
+ * An unnamed relative range is described in its own terms — "Last 90 minutes" —
436
+ * because showing it as two timestamps throws away what the user chose. Only an
437
+ * absolute range, which has no relative meaning left, is shown as dates.
438
+ */
439
+ export function describeTimeRange(
440
+ expression: string,
441
+ options: ResolveOptions & { locale?: string; presets?: TimeRangePreset[] },
442
+ ): string {
443
+ const presets = options.presets ?? DEFAULT_PRESETS;
444
+ const preset = presets.find((candidate) => candidate.expression === expression);
445
+ if (preset) return preset.label;
446
+
447
+ const relative = RELATIVE.exec(expression.trim());
448
+ if (relative) {
449
+ const amount = Number(relative[1]);
450
+ const unit = UNIT_NAMES[relative[2] as TimeUnit];
451
+ return `Last ${String(amount)} ${unit}${amount === 1 ? "" : "s"}`;
452
+ }
453
+
454
+ try {
455
+ const { from, to } = resolveTimeRange(expression, options);
456
+ const format = new Intl.DateTimeFormat(options.locale, {
457
+ dateStyle: "medium",
458
+ timeStyle: "short",
459
+ timeZone: options.timeZone,
460
+ });
461
+ return `${format.format(from)} – ${format.format(to)}`;
462
+ } catch {
463
+ return expression;
464
+ }
465
+ }
466
+
467
+ /** The resolved window, spelled out. Pairs with the relative label, not replaces it. */
468
+ export function formatResolvedRange(
469
+ range: ResolvedRange,
470
+ options: { locale?: string; timeZone?: string } = {},
471
+ ): string {
472
+ const format = new Intl.DateTimeFormat(options.locale, {
473
+ dateStyle: "medium",
474
+ timeStyle: "medium",
475
+ timeZone: options.timeZone,
476
+ });
477
+ return `${format.format(range.from)} – ${format.format(range.to)}`;
478
+ }
479
+
480
+ /** Builds the expression for an absolute range chosen on a calendar. */
481
+ export function absoluteExpression(from: Date, to: Date): string {
482
+ return `${toLocalIso(from)}..${toLocalIso(to)}`;
483
+ }
484
+
485
+ function toLocalIso(date: Date): string {
486
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
487
+ return (
488
+ `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
489
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
490
+ );
491
+ }