@dowel-ui/react 0.4.0 → 0.5.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 (146) hide show
  1. package/README.md +9 -5
  2. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  3. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  4. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  5. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  6. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  7. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  8. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  9. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  10. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  11. package/dist/components/ai-extraction-review/index.js +3 -0
  12. package/dist/components/ai-extraction-review/meta.js +17 -0
  13. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  14. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  15. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  16. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  17. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  18. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  19. package/dist/components/ai-suggested-value/index.js +2 -0
  20. package/dist/components/ai-suggested-value/meta.js +17 -0
  21. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  22. package/dist/components/alert/alert.d.ts +1 -1
  23. package/dist/components/avatar/avatar.d.ts +1 -1
  24. package/dist/components/badge/badge.d.ts +1 -1
  25. package/dist/components/button/button.d.ts +1 -1
  26. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  27. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  28. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  29. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  30. package/dist/components/confirm-typed/index.d.ts +2 -0
  31. package/dist/components/confirm-typed/index.js +2 -0
  32. package/dist/components/confirm-typed/meta.js +17 -0
  33. package/dist/components/confirm-typed/meta.js.map +1 -0
  34. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  35. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  36. package/dist/components/cron-editor/cron-editor.js +493 -0
  37. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  38. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  39. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  40. package/dist/components/cron-editor/cron-expression.js +352 -0
  41. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  42. package/dist/components/cron-editor/index.d.ts +3 -0
  43. package/dist/components/cron-editor/index.js +3 -0
  44. package/dist/components/cron-editor/meta.js +17 -0
  45. package/dist/components/cron-editor/meta.js.map +1 -0
  46. package/dist/components/dns-record/dns-record.d.ts +61 -0
  47. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  48. package/dist/components/dns-record/dns-record.js +227 -0
  49. package/dist/components/dns-record/dns-record.js.map +1 -0
  50. package/dist/components/dns-record/index.d.ts +2 -0
  51. package/dist/components/dns-record/index.js +2 -0
  52. package/dist/components/dns-record/meta.js +17 -0
  53. package/dist/components/dns-record/meta.js.map +1 -0
  54. package/dist/components/permission-matrix/index.d.ts +3 -0
  55. package/dist/components/permission-matrix/index.js +3 -0
  56. package/dist/components/permission-matrix/meta.js +17 -0
  57. package/dist/components/permission-matrix/meta.js.map +1 -0
  58. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  59. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  60. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  61. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  62. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  63. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  64. package/dist/components/permission-matrix/permission-model.js +64 -0
  65. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  66. package/dist/components/progress/progress.d.ts +1 -1
  67. package/dist/components/secret-field/index.d.ts +2 -0
  68. package/dist/components/secret-field/index.js +2 -0
  69. package/dist/components/secret-field/meta.js +17 -0
  70. package/dist/components/secret-field/meta.js.map +1 -0
  71. package/dist/components/secret-field/secret-field.d.ts +58 -0
  72. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  73. package/dist/components/secret-field/secret-field.js +168 -0
  74. package/dist/components/secret-field/secret-field.js.map +1 -0
  75. package/dist/components/session-expiry/index.d.ts +2 -0
  76. package/dist/components/session-expiry/index.js +2 -0
  77. package/dist/components/session-expiry/meta.js +17 -0
  78. package/dist/components/session-expiry/meta.js.map +1 -0
  79. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  80. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  81. package/dist/components/session-expiry/session-expiry.js +185 -0
  82. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  83. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  84. package/dist/components/shortcut-recorder/index.js +3 -0
  85. package/dist/components/shortcut-recorder/meta.js +17 -0
  86. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  87. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  88. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  89. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  90. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  91. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  92. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  93. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  94. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  95. package/dist/components/spinner/spinner.d.ts +1 -1
  96. package/dist/components/sync-status/index.d.ts +2 -0
  97. package/dist/components/sync-status/index.js +2 -0
  98. package/dist/components/sync-status/meta.js +17 -0
  99. package/dist/components/sync-status/meta.js.map +1 -0
  100. package/dist/components/sync-status/sync-status.d.ts +65 -0
  101. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  102. package/dist/components/sync-status/sync-status.js +103 -0
  103. package/dist/components/sync-status/sync-status.js.map +1 -0
  104. package/dist/components/toast/toast.d.ts +1 -1
  105. package/dist/index.d.ts +25 -1
  106. package/dist/index.js +16 -2
  107. package/dist/registry/components.d.ts.map +1 -1
  108. package/dist/registry/components.js +73 -53
  109. package/dist/registry/components.js.map +1 -1
  110. package/package.json +4 -4
  111. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  112. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  113. package/src/components/ai-extraction-review/index.ts +13 -0
  114. package/src/components/ai-extraction-review/meta.ts +24 -0
  115. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  116. package/src/components/ai-suggested-value/index.ts +6 -0
  117. package/src/components/ai-suggested-value/meta.ts +24 -0
  118. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  119. package/src/components/confirm-typed/index.ts +1 -0
  120. package/src/components/confirm-typed/meta.ts +22 -0
  121. package/src/components/cron-editor/cron-editor.tsx +676 -0
  122. package/src/components/cron-editor/cron-expression.ts +552 -0
  123. package/src/components/cron-editor/index.ts +23 -0
  124. package/src/components/cron-editor/meta.ts +23 -0
  125. package/src/components/dns-record/dns-record.tsx +315 -0
  126. package/src/components/dns-record/index.ts +7 -0
  127. package/src/components/dns-record/meta.ts +21 -0
  128. package/src/components/permission-matrix/index.ts +14 -0
  129. package/src/components/permission-matrix/meta.ts +23 -0
  130. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  131. package/src/components/permission-matrix/permission-model.ts +120 -0
  132. package/src/components/secret-field/index.ts +6 -0
  133. package/src/components/secret-field/meta.ts +21 -0
  134. package/src/components/secret-field/secret-field.tsx +284 -0
  135. package/src/components/session-expiry/index.ts +9 -0
  136. package/src/components/session-expiry/meta.ts +23 -0
  137. package/src/components/session-expiry/session-expiry.tsx +321 -0
  138. package/src/components/shortcut-recorder/index.ts +19 -0
  139. package/src/components/shortcut-recorder/meta.ts +22 -0
  140. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  141. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  142. package/src/components/sync-status/index.ts +9 -0
  143. package/src/components/sync-status/meta.ts +21 -0
  144. package/src/components/sync-status/sync-status.tsx +217 -0
  145. package/src/index.ts +10 -0
  146. package/src/registry/components.ts +20 -0
@@ -0,0 +1,552 @@
1
+ /**
2
+ * The five-field cron expression, as a value: parsed, described in words, and
3
+ * projected onto a calendar in a named time zone.
4
+ *
5
+ * The dialect is the POSIX one — minute, hour, day of month, month, day of
6
+ * week — which is what crontab, GitHub Actions, Vercel, Kubernetes and Airflow
7
+ * all read. Not the Quartz one: no seconds field and no `L`, `W` or `#`,
8
+ * because the products people schedule from a form do not accept them, and an
9
+ * expression this editor produces has to run where it is pasted. The `@daily`
10
+ * family of shortcuts is accepted, since crontab accepts it.
11
+ *
12
+ * Everything here is pure and takes its clock as an argument, so the
13
+ * description, the run times and the two things every reimplementation gets
14
+ * wrong — day-of-month OR day-of-week, and a wall-clock time that does not
15
+ * exist on the day the clocks go forward — are tested without rendering
16
+ * anything, and an application can compute the next run on the server from
17
+ * the same expression the editor produced.
18
+ */
19
+
20
+ export type CronField = "minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek";
21
+
22
+ export class CronExpressionError extends Error {
23
+ constructor(
24
+ message: string,
25
+ /** Which of the five fields is wrong, when one is. */
26
+ public readonly field?: CronField,
27
+ ) {
28
+ super(message);
29
+ this.name = "CronExpressionError";
30
+ }
31
+ }
32
+
33
+ export interface CronSchedule {
34
+ minutes: number[];
35
+ hours: number[];
36
+ daysOfMonth: number[];
37
+ months: number[];
38
+ /** 0 to 6, Sunday first. `7` in the source is folded into 0. */
39
+ daysOfWeek: number[];
40
+ /**
41
+ * Whether each day field was written as something other than `*`. Cron's
42
+ * rule, which every reimplementation gets wrong at least once: when both are
43
+ * restricted a day matches if EITHER does, not both.
44
+ */
45
+ dayOfMonthRestricted: boolean;
46
+ dayOfWeekRestricted: boolean;
47
+ /** The expression with any shortcut expanded. */
48
+ expression: string;
49
+ }
50
+
51
+ const FIELDS: readonly CronField[] = ["minute", "hour", "dayOfMonth", "month", "dayOfWeek"];
52
+
53
+ const RANGES: Record<CronField, { min: number; max: number; label: string }> = {
54
+ minute: { min: 0, max: 59, label: "Minute" },
55
+ hour: { min: 0, max: 23, label: "Hour" },
56
+ dayOfMonth: { min: 1, max: 31, label: "Day of month" },
57
+ month: { min: 1, max: 12, label: "Month" },
58
+ dayOfWeek: { min: 0, max: 7, label: "Day of week" },
59
+ };
60
+
61
+ const MONTH_NAMES = [
62
+ "JAN",
63
+ "FEB",
64
+ "MAR",
65
+ "APR",
66
+ "MAY",
67
+ "JUN",
68
+ "JUL",
69
+ "AUG",
70
+ "SEP",
71
+ "OCT",
72
+ "NOV",
73
+ "DEC",
74
+ ];
75
+ const DAY_NAMES = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
76
+
77
+ export const CRON_SHORTCUTS: Record<string, string> = {
78
+ "@yearly": "0 0 1 1 *",
79
+ "@annually": "0 0 1 1 *",
80
+ "@monthly": "0 0 1 * *",
81
+ "@weekly": "0 0 * * 0",
82
+ "@daily": "0 0 * * *",
83
+ "@midnight": "0 0 * * *",
84
+ "@hourly": "0 * * * *",
85
+ };
86
+
87
+ function nameToNumber(token: string, field: CronField): number | null {
88
+ const upper = token.toUpperCase();
89
+ if (field === "month") {
90
+ const index = MONTH_NAMES.indexOf(upper);
91
+ return index === -1 ? null : index + 1;
92
+ }
93
+ if (field === "dayOfWeek") {
94
+ const index = DAY_NAMES.indexOf(upper);
95
+ return index === -1 ? null : index;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function parseNumber(token: string, field: CronField): number {
101
+ const { min, max, label } = RANGES[field];
102
+ const named = nameToNumber(token, field);
103
+ const value = named ?? (/^\d+$/.test(token) ? Number(token) : Number.NaN);
104
+ if (Number.isNaN(value)) {
105
+ throw new CronExpressionError(
106
+ `${label}: "${token}" is not a number${field === "month" || field === "dayOfWeek" ? " or a name" : ""}.`,
107
+ field,
108
+ );
109
+ }
110
+ if (value < min || value > max) {
111
+ throw new CronExpressionError(
112
+ `${label} must be ${String(min)}–${String(max)}, not ${String(value)}.`,
113
+ field,
114
+ );
115
+ }
116
+ return value;
117
+ }
118
+
119
+ /** One field's set of values, in order and without duplicates. */
120
+ function parseField(text: string, field: CronField): { values: number[]; restricted: boolean } {
121
+ const { min, max, label } = RANGES[field];
122
+ // Day of week runs 0–7 in the source and 0–6 in the result.
123
+ const top = field === "dayOfWeek" ? 6 : max;
124
+ const values = new Set<number>();
125
+ let restricted = false;
126
+
127
+ for (const part of text.split(",")) {
128
+ if (part === "") throw new CronExpressionError(`${label}: empty item in "${text}".`, field);
129
+
130
+ const [rangeText, stepText, ...extra] = part.split("/");
131
+ if (extra.length > 0 || stepText === "") {
132
+ throw new CronExpressionError(`${label}: "${part}" has a malformed step.`, field);
133
+ }
134
+ const step = stepText === undefined ? 1 : Number(stepText);
135
+ if (stepText !== undefined && (!/^\d+$/.test(stepText) || step < 1)) {
136
+ throw new CronExpressionError(
137
+ `${label}: step must be a whole number of 1 or more, not "${stepText}".`,
138
+ field,
139
+ );
140
+ }
141
+
142
+ let start: number;
143
+ let end: number;
144
+ if (rangeText === "*") {
145
+ start = min;
146
+ end = top;
147
+ if (stepText !== undefined) restricted = true;
148
+ } else if (rangeText === undefined) {
149
+ throw new CronExpressionError(`${label}: "${part}" is empty.`, field);
150
+ } else {
151
+ restricted = true;
152
+ const [startText, endText, ...more] = rangeText.split("-");
153
+ if (more.length > 0 || startText === undefined || startText === "") {
154
+ throw new CronExpressionError(`${label}: "${rangeText}" is not a range.`, field);
155
+ }
156
+ start = parseNumber(startText, field);
157
+ if (endText === undefined) {
158
+ // A bare number with a step, `5/10`, means "from 5 onwards" in Vixie cron.
159
+ end = stepText === undefined ? start : top;
160
+ } else {
161
+ if (endText === "")
162
+ throw new CronExpressionError(`${label}: "${rangeText}" is missing its end.`, field);
163
+ end = parseNumber(endText, field);
164
+ if (end < start) {
165
+ throw new CronExpressionError(
166
+ `${label}: ${String(start)}-${String(end)} runs backwards.`,
167
+ field,
168
+ );
169
+ }
170
+ }
171
+ }
172
+
173
+ for (let value = start; value <= end; value += step) {
174
+ values.add(field === "dayOfWeek" && value === 7 ? 0 : value);
175
+ }
176
+ }
177
+
178
+ return { values: [...values].sort((a, b) => a - b), restricted };
179
+ }
180
+
181
+ export function parseCron(expression: string): CronSchedule {
182
+ const trimmed = expression.trim();
183
+ if (trimmed === "") throw new CronExpressionError("Enter a schedule.");
184
+
185
+ const expanded = trimmed.startsWith("@") ? CRON_SHORTCUTS[trimmed.toLowerCase()] : trimmed;
186
+ if (expanded === undefined) {
187
+ throw new CronExpressionError(
188
+ `"${trimmed}" is not a shortcut. Try @hourly, @daily, @weekly, @monthly or @yearly.`,
189
+ );
190
+ }
191
+
192
+ const parts = expanded.split(/\s+/);
193
+ if (parts.length !== 5) {
194
+ throw new CronExpressionError(
195
+ `Expected 5 fields — minute, hour, day of month, month, day of week — not ${String(parts.length)}.`,
196
+ );
197
+ }
198
+
199
+ const fields = FIELDS.map((field, index) => parseField(parts[index] ?? "", field));
200
+ const at = (index: number) => fields[index] ?? { values: [], restricted: false };
201
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = [at(0), at(1), at(2), at(3), at(4)];
202
+
203
+ return {
204
+ minutes: minute.values,
205
+ hours: hour.values,
206
+ daysOfMonth: dayOfMonth.values,
207
+ months: month.values,
208
+ daysOfWeek: dayOfWeek.values,
209
+ dayOfMonthRestricted: dayOfMonth.restricted,
210
+ dayOfWeekRestricted: dayOfWeek.restricted,
211
+ expression: parts.join(" "),
212
+ };
213
+ }
214
+
215
+ export function isValidCron(expression: string): boolean {
216
+ try {
217
+ parseCron(expression);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /* ------------------------------------------------------------------ */
225
+ /* Describing */
226
+ /* ------------------------------------------------------------------ */
227
+
228
+ export interface DescribeOptions {
229
+ locale?: string;
230
+ }
231
+
232
+ /** "1, 2 and 3", or "1 to 5" when the values are consecutive. */
233
+ function listOf(items: string[], consecutive: boolean): string {
234
+ if (items.length === 1) return items[0] ?? "";
235
+ if (consecutive && items.length > 2)
236
+ return `${items[0] ?? ""} to ${items[items.length - 1] ?? ""}`;
237
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1] ?? ""}`;
238
+ }
239
+
240
+ function isConsecutive(values: number[]): boolean {
241
+ return values.every((value, index) => index === 0 || value === (values[index - 1] ?? 0) + 1);
242
+ }
243
+
244
+ /** The step a field was written with, when it was written as a star over a number. */
245
+ function stepOf(text: string): number | null {
246
+ const match = /^\*\/(\d+)$/.exec(text);
247
+ return match ? Number(match[1]) : null;
248
+ }
249
+
250
+ function ordinal(n: number): string {
251
+ const rem10 = n % 10;
252
+ const rem100 = n % 100;
253
+ const suffix =
254
+ rem10 === 1 && rem100 !== 11
255
+ ? "st"
256
+ : rem10 === 2 && rem100 !== 12
257
+ ? "nd"
258
+ : rem10 === 3 && rem100 !== 13
259
+ ? "rd"
260
+ : "th";
261
+ return `${String(n)}${suffix}`;
262
+ }
263
+
264
+ function formatTime(hour: number, minute: number, locale?: string): string {
265
+ // Formatted in UTC from a UTC instant, so the runtime zone cannot shift it.
266
+ return new Intl.DateTimeFormat(locale, {
267
+ hour: "2-digit",
268
+ minute: "2-digit",
269
+ timeZone: "UTC",
270
+ }).format(Date.UTC(2026, 0, 1, hour, minute));
271
+ }
272
+
273
+ function dayName(index: number, locale?: string): string {
274
+ // 4 January 2026 is a Sunday.
275
+ return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(
276
+ new Date(2026, 0, 4 + index),
277
+ );
278
+ }
279
+
280
+ function monthName(index: number, locale?: string): string {
281
+ return new Intl.DateTimeFormat(locale, { month: "long" }).format(
282
+ new Date(2026, index - 1, 1),
283
+ );
284
+ }
285
+
286
+ /**
287
+ * The schedule in a sentence.
288
+ *
289
+ * Every product that has this control renders "Every Monday at 09:00" beside
290
+ * the expression, because `0 9 * * 1` is not something most people can read.
291
+ * Throws for an invalid expression; the editor turns that into the field's
292
+ * error text.
293
+ */
294
+ export function describeCron(expression: string, options: DescribeOptions = {}): string {
295
+ const { locale } = options;
296
+ const schedule = parseCron(expression);
297
+ const [minuteText = "", hourText = "", dayText = "", monthText = ""] =
298
+ schedule.expression.split(" ");
299
+
300
+ const every = (label: string, n: number) =>
301
+ n === 1 ? `every ${label}` : `every ${ordinal(n)} ${label}`;
302
+
303
+ // Time of day.
304
+ let time: string;
305
+ const minuteStep = stepOf(minuteText);
306
+ const hourStep = stepOf(hourText);
307
+ const allMinutes = minuteText === "*";
308
+ const allHours = hourText === "*";
309
+
310
+ if (allMinutes && allHours) {
311
+ time = "Every minute";
312
+ } else if (minuteStep !== null && allHours) {
313
+ time = `Every ${minuteStep === 1 ? "minute" : `${String(minuteStep)} minutes`}`;
314
+ } else if (allMinutes) {
315
+ time = `Every minute of ${listOf(
316
+ schedule.hours.map((h) => formatTime(h, 0, locale)),
317
+ isConsecutive(schedule.hours),
318
+ )}`;
319
+ } else if (allHours || hourStep !== null) {
320
+ const hours = hourStep !== null && hourStep > 1 ? every("hour", hourStep) : "every hour";
321
+ if (minuteStep !== null) {
322
+ time = `Every ${String(minuteStep)} minutes of ${hours}`;
323
+ } else {
324
+ time = `At minute ${listOf(schedule.minutes.map(String), isConsecutive(schedule.minutes))} past ${hours}`;
325
+ }
326
+ } else if (minuteStep !== null) {
327
+ time = `Every ${String(minuteStep)} minutes during ${listOf(
328
+ schedule.hours.map((h) => formatTime(h, 0, locale)),
329
+ isConsecutive(schedule.hours),
330
+ )}`;
331
+ } else if (schedule.minutes.length === 1) {
332
+ const minute = schedule.minutes[0] ?? 0;
333
+ time = `At ${listOf(
334
+ schedule.hours.map((h) => formatTime(h, minute, locale)),
335
+ false,
336
+ )}`;
337
+ } else {
338
+ time = `At minute ${listOf(schedule.minutes.map(String), isConsecutive(schedule.minutes))} past ${listOf(
339
+ schedule.hours.map((h) => formatTime(h, 0, locale)),
340
+ isConsecutive(schedule.hours),
341
+ )}`;
342
+ }
343
+
344
+ // Days. When both day fields are restricted a day matches if either does —
345
+ // said with "or", because "and" is what readers assume and it is wrong.
346
+ const dayPhrases: string[] = [];
347
+ if (schedule.dayOfMonthRestricted) {
348
+ const step = stepOf(dayText);
349
+ dayPhrases.push(
350
+ step !== null
351
+ ? `${every("day", step)} of the month`
352
+ : `on day ${listOf(schedule.daysOfMonth.map(String), isConsecutive(schedule.daysOfMonth))} of the month`,
353
+ );
354
+ }
355
+ if (schedule.dayOfWeekRestricted) {
356
+ dayPhrases.push(
357
+ `on ${listOf(
358
+ schedule.daysOfWeek.map((d) => dayName(d, locale)),
359
+ isConsecutive(schedule.daysOfWeek),
360
+ )}`,
361
+ );
362
+ }
363
+ let days = dayPhrases.join(", or ");
364
+
365
+ // A fixed time of day with no day named is every day. "Every 15 minutes"
366
+ // already says how often, and "every day" after it would be noise.
367
+ const fixedTime = !allMinutes && !allHours && minuteStep === null && hourStep === null;
368
+ if (!days && fixedTime) days = "every day";
369
+
370
+ if (schedule.months.length < 12) {
371
+ const step = stepOf(monthText);
372
+ const months =
373
+ step !== null
374
+ ? every("month", step)
375
+ : listOf(
376
+ schedule.months.map((m) => monthName(m, locale)),
377
+ isConsecutive(schedule.months),
378
+ );
379
+ days = `${days ? `${days} ` : ""}in ${months}`;
380
+ }
381
+
382
+ return days ? `${time} ${days}` : time;
383
+ }
384
+
385
+ /* ------------------------------------------------------------------ */
386
+ /* Next runs */
387
+ /* ------------------------------------------------------------------ */
388
+
389
+ export interface NextRunsOptions {
390
+ /** Runs strictly after this instant. */
391
+ from: Date;
392
+ count?: number;
393
+ /** IANA zone the expression's wall-clock times are in. Defaults to the runtime zone. */
394
+ timeZone?: string;
395
+ }
396
+
397
+ interface WallClock {
398
+ year: number;
399
+ month: number;
400
+ day: number;
401
+ hour: number;
402
+ minute: number;
403
+ /** 0 = Sunday. */
404
+ weekday: number;
405
+ }
406
+
407
+ const formatters = new Map<string, Intl.DateTimeFormat>();
408
+
409
+ function formatterFor(timeZone: string | undefined): Intl.DateTimeFormat {
410
+ const key = timeZone ?? "";
411
+ let formatter = formatters.get(key);
412
+ if (!formatter) {
413
+ formatter = new Intl.DateTimeFormat("en-US", {
414
+ timeZone,
415
+ hourCycle: "h23",
416
+ year: "numeric",
417
+ month: "numeric",
418
+ day: "numeric",
419
+ hour: "numeric",
420
+ minute: "numeric",
421
+ weekday: "short",
422
+ });
423
+ formatters.set(key, formatter);
424
+ }
425
+ return formatter;
426
+ }
427
+
428
+ function wallClockOf(instant: number, timeZone: string | undefined): WallClock {
429
+ const parts: Record<string, string> = {};
430
+ for (const part of formatterFor(timeZone).formatToParts(new Date(instant))) {
431
+ parts[part.type] = part.value;
432
+ }
433
+ return {
434
+ year: Number(parts.year),
435
+ month: Number(parts.month),
436
+ day: Number(parts.day),
437
+ hour: Number(parts.hour) % 24,
438
+ minute: Number(parts.minute),
439
+ weekday: DAY_NAMES.indexOf((parts.weekday ?? "").slice(0, 3).toUpperCase()),
440
+ };
441
+ }
442
+
443
+ function offsetAt(instant: number, timeZone: string | undefined): number {
444
+ const wall = wallClockOf(instant, timeZone);
445
+ return (
446
+ Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute) -
447
+ Math.floor(instant / 60_000) * 60_000
448
+ );
449
+ }
450
+
451
+ /**
452
+ * The instant a wall-clock time names in a zone, or null when it names none —
453
+ * the hour the clocks skip in spring. An ambiguous time, in the hour that
454
+ * repeats in autumn, resolves to its first occurrence.
455
+ */
456
+ export function zonedTimeToInstant(
457
+ year: number,
458
+ month: number,
459
+ day: number,
460
+ hour: number,
461
+ minute: number,
462
+ timeZone: string | undefined,
463
+ ): number | null {
464
+ const guess = Date.UTC(year, month - 1, day, hour, minute);
465
+ // Each pass corrects the guess by the offset in force at the previous
466
+ // answer; across a DST change the two answers are the two candidates.
467
+ const candidates = new Set<number>();
468
+ // Seeded from a day either side as well, so the offset that stopped
469
+ // applying an hour ago — the one that makes an autumn time ambiguous — is
470
+ // still tried.
471
+ const oneDay = 24 * 60 * 60_000;
472
+ for (const seed of [guess, guess - oneDay, guess + oneDay]) {
473
+ let instant = seed;
474
+ for (let pass = 0; pass < 2; pass += 1) {
475
+ instant = guess - offsetAt(instant, timeZone);
476
+ candidates.add(instant);
477
+ }
478
+ }
479
+ for (const candidate of [...candidates].sort((a, b) => a - b)) {
480
+ const wall = wallClockOf(candidate, timeZone);
481
+ if (
482
+ wall.year === year &&
483
+ wall.month === month &&
484
+ wall.day === day &&
485
+ wall.hour === hour &&
486
+ wall.minute === minute
487
+ ) {
488
+ return candidate;
489
+ }
490
+ }
491
+ return null;
492
+ }
493
+
494
+ /** Roughly five years of days: enough to find the 29th of February, and to give up on the 30th. */
495
+ const MAX_DAYS = 366 * 5;
496
+
497
+ /**
498
+ * The next `count` instants the schedule fires, in order.
499
+ *
500
+ * Fewer than `count` are returned when the schedule cannot produce them —
501
+ * `0 0 30 2 *` never runs, and saying so beats a spinner.
502
+ */
503
+ export function nextRuns(expression: string, options: NextRunsOptions): Date[] {
504
+ const { from, count = 5, timeZone } = options;
505
+ const schedule = parseCron(expression);
506
+ const runs: Date[] = [];
507
+
508
+ const start = Math.floor(from.getTime() / 60_000) * 60_000 + 60_000;
509
+ const months = new Set(schedule.months);
510
+ const daysOfMonth = new Set(schedule.daysOfMonth);
511
+ const daysOfWeek = new Set(schedule.daysOfWeek);
512
+
513
+ const dayMatches = (wall: WallClock): boolean => {
514
+ if (!months.has(wall.month)) return false;
515
+ const domMatch = daysOfMonth.has(wall.day);
516
+ const dowMatch = daysOfWeek.has(wall.weekday);
517
+ if (schedule.dayOfMonthRestricted && schedule.dayOfWeekRestricted)
518
+ return domMatch || dowMatch;
519
+ if (schedule.dayOfMonthRestricted) return domMatch;
520
+ if (schedule.dayOfWeekRestricted) return dowMatch;
521
+ return true;
522
+ };
523
+
524
+ // Walk day by day from noon, which no DST change touches, so that stepping
525
+ // 24 hours always lands in the next calendar day.
526
+ const first = wallClockOf(start, timeZone);
527
+ let noon = zonedTimeToInstant(first.year, first.month, first.day, 12, 0, timeZone);
528
+ if (noon === null) return runs;
529
+
530
+ for (let i = 0; i < MAX_DAYS && runs.length < count; i += 1, noon += 24 * 60 * 60_000) {
531
+ const wall = wallClockOf(noon, timeZone);
532
+ if (!dayMatches(wall)) continue;
533
+
534
+ for (const hour of schedule.hours) {
535
+ for (const minute of schedule.minutes) {
536
+ const instant = zonedTimeToInstant(
537
+ wall.year,
538
+ wall.month,
539
+ wall.day,
540
+ hour,
541
+ minute,
542
+ timeZone,
543
+ );
544
+ if (instant === null || instant < start) continue;
545
+ runs.push(new Date(instant));
546
+ if (runs.length >= count) return runs;
547
+ }
548
+ }
549
+ }
550
+
551
+ return runs;
552
+ }
@@ -0,0 +1,23 @@
1
+ export {
2
+ Cron,
3
+ CronBuilder,
4
+ CronExpression,
5
+ CronNextRuns,
6
+ type CronBuilderProps,
7
+ type CronExpressionProps,
8
+ type CronNextRunsProps,
9
+ type CronProps,
10
+ } from "./cron-editor";
11
+ export {
12
+ CRON_SHORTCUTS,
13
+ CronExpressionError,
14
+ describeCron,
15
+ isValidCron,
16
+ nextRuns,
17
+ parseCron,
18
+ zonedTimeToInstant,
19
+ type CronField,
20
+ type CronSchedule,
21
+ type DescribeOptions,
22
+ type NextRunsOptions,
23
+ } from "./cron-expression";
@@ -0,0 +1,23 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "cron-editor",
5
+ title: "Cron Editor",
6
+ description:
7
+ "A schedule as a cron expression, with a plain-language reading and the next runs.",
8
+ category: "form",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: ["input", "select"],
12
+ files: ["cron-expression.ts", "cron-editor.tsx"],
13
+ a11y:
14
+ "The expression field is described by one element that holds either the plain-language " +
15
+ "reading or the reason the expression is invalid, so a reader hears the error replace the " +
16
+ "reading rather than both at once; an invalid expression is never applied. The builder's " +
17
+ "day buttons are a named group of aria-pressed toggles with the full day name as the " +
18
+ "accessible name, and the last selected day cannot be removed silently — the group says why. " +
19
+ "Days 29 to 31 say in text that shorter months skip them. Next runs are a list of time " +
20
+ "elements with machine-readable datetimes, headed by the zone they are in, because a time " +
21
+ "with no zone is the classic scheduling mistake. Nothing clock-dependent renders until the " +
22
+ "clock is known, so server and client cannot disagree.",
23
+ });