@sentry/junior-dashboard 0.232.0 → 0.234.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 (45) hide show
  1. package/dist/app.js +163 -46
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +163 -46
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/conversations/ConversationEventLog.tsx +49 -9
  18. package/src/client/conversations/EventDetails.tsx +94 -11
  19. package/src/client/conversations/EventUsage.tsx +118 -0
  20. package/src/client/conversations/eventLog.ts +59 -6
  21. package/src/client/conversations/eventTranscript.ts +2 -0
  22. package/src/client/conversations/transcriptBottomPinning.ts +2 -2
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  25. package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
  26. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  27. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  28. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  29. package/src/client/pages/automations/AutomationFilters.tsx +171 -0
  30. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  31. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  32. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  33. package/src/client/pages/automations/AutomationRow.tsx +358 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  37. package/src/client/pages/automations/README.md +19 -0
  38. package/src/client/pages/automations/automationDraft.ts +110 -0
  39. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  40. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  41. package/src/client/searchParams.ts +4 -2
  42. package/src/client.tsx +7 -4
  43. package/src/mock-reporting/automation-collection.ts +101 -0
  44. package/src/mock-reporting/fixtures.ts +82 -32
  45. package/src/mock-reporting/routes.ts +14 -9
@@ -0,0 +1,358 @@
1
+ import type { AutomationSummary } from "@sentry/junior/api/schema";
2
+ import {
3
+ CalendarClock,
4
+ LockKeyhole,
5
+ MoreHorizontal,
6
+ Trash2,
7
+ Zap,
8
+ } from "lucide-react";
9
+ import { useEffect, useId, useRef, useState } from "react";
10
+ import { Link, useLocation } from "react-router";
11
+ import { ActorAvatar } from "../../components/ActorAvatar";
12
+ import { SelectableRow } from "../../components/SelectableRow";
13
+ import { StatusChip } from "../../components/StatusChip";
14
+ import { formatRelativeTime, formatTime, peoplePath } from "../../format";
15
+ import { cn } from "../../styles";
16
+
17
+ const desktopColumns =
18
+ "xl:grid-cols-[minmax(0,1fr)_9rem_16rem_10rem_7rem_2.75rem]";
19
+
20
+ /** Keep the management headings aligned with the row fields. */
21
+ export function AutomationListHeader() {
22
+ return (
23
+ <div
24
+ aria-hidden="true"
25
+ className={cn(
26
+ "hidden items-center gap-4 border-b border-dashboard-border-subtle px-4 py-2.5 text-xs text-dashboard-text-muted xl:grid",
27
+ desktopColumns,
28
+ )}
29
+ >
30
+ <span>Automation</span>
31
+ <span>Creator</span>
32
+ <span>Trigger</span>
33
+ <span>Destination</span>
34
+ <span>Last run</span>
35
+ <span className="sr-only">Actions</span>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /** Show purpose, ownership, and trigger state without opening the details. */
41
+ export function AutomationRow(props: {
42
+ deleting: boolean;
43
+ onDelete(): void;
44
+ onSelect(): void;
45
+ selected: boolean;
46
+ automation: AutomationSummary;
47
+ }) {
48
+ const { automation } = props;
49
+ const location = useLocation();
50
+ const creator = (
51
+ <>
52
+ <ActorAvatar
53
+ imageUrl={automation.createdByAvatarUrl}
54
+ name={automation.createdBy}
55
+ size="list"
56
+ />
57
+ <span className="truncate">{automation.createdBy}</span>
58
+ </>
59
+ );
60
+ return (
61
+ <article role="listitem">
62
+ <SelectableRow
63
+ className={cn(
64
+ "relative grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-4 py-3 xl:gap-x-4",
65
+ desktopColumns,
66
+ )}
67
+ onSelect={props.onSelect}
68
+ selected={props.selected}
69
+ >
70
+ <button
71
+ aria-expanded={props.selected}
72
+ aria-label={`View automation details: ${automation.title}`}
73
+ className="col-span-2 pr-11 xl:pr-0 min-w-0 cursor-pointer rounded border-0 bg-transparent p-0 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus xl:col-span-1"
74
+ onClick={props.onSelect}
75
+ type="button"
76
+ >
77
+ <span
78
+ className="block truncate font-display text-base font-medium text-dashboard-text"
79
+ title={automation.title}
80
+ >
81
+ {automation.title}
82
+ </span>
83
+ <span
84
+ className="mt-1 block truncate text-xs text-dashboard-text-muted"
85
+ title={automation.instruction}
86
+ >
87
+ {automation.instruction}
88
+ </span>
89
+ </button>
90
+ <div className="row-start-2 min-w-0 xl:row-auto">
91
+ {automation.createdByEmail ? (
92
+ <Link
93
+ className="inline-flex max-w-full items-center gap-2 rounded text-sm text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
94
+ title={automation.createdBy}
95
+ to={peoplePath(automation.createdByEmail)}
96
+ >
97
+ {creator}
98
+ </Link>
99
+ ) : (
100
+ <span
101
+ className="inline-flex max-w-full items-center gap-2 text-sm text-dashboard-text"
102
+ title={automation.createdBy}
103
+ >
104
+ {creator}
105
+ </span>
106
+ )}
107
+ </div>
108
+ <div className="col-span-2 row-start-3 min-w-0 xl:col-span-1 xl:row-auto">
109
+ <AutomationTrigger automation={automation} />
110
+ </div>
111
+ <div className="col-start-2 row-start-2 min-w-0 text-right xl:col-auto xl:row-auto xl:text-left">
112
+ <span className="inline-flex max-w-full items-center gap-1 text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text">
113
+ {automation.destination.visibility === "private" ? (
114
+ <LockKeyhole
115
+ aria-label="Private destination"
116
+ role="img"
117
+ size={12}
118
+ className="shrink-0"
119
+ />
120
+ ) : null}
121
+ <span className="truncate">{automation.destination.label}</span>
122
+ </span>
123
+ </div>
124
+ <div className="col-span-2 min-w-0 xl:col-span-1">
125
+ <span className="text-xs text-dashboard-text-muted xl:hidden">
126
+ Last run{" "}
127
+ </span>
128
+ <span
129
+ className="text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text"
130
+ title={
131
+ automation.lastRunAt
132
+ ? formatTime(automation.lastRunAt, {
133
+ dateStyle: "medium",
134
+ timeStyle: "short",
135
+ })
136
+ : undefined
137
+ }
138
+ >
139
+ {automation.lastRunAt
140
+ ? formatRelativeTime(automation.lastRunAt)
141
+ : "Never"}
142
+ </span>
143
+ {automation.lastRunStatus === "failed" ||
144
+ automation.lastRunStatus === "blocked" ? (
145
+ <Link
146
+ className="block text-xs text-amber-300 underline"
147
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
148
+ >
149
+ Last run {automation.lastRunStatus}
150
+ </Link>
151
+ ) : null}
152
+ </div>
153
+ <div className="absolute right-3 top-3 xl:static">
154
+ {automation.ownedByViewer ? (
155
+ <AutomationActions
156
+ editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
157
+ deleting={props.deleting}
158
+ onDelete={props.onDelete}
159
+ title={automation.title}
160
+ />
161
+ ) : null}
162
+ </div>
163
+ </SelectableRow>
164
+ </article>
165
+ );
166
+ }
167
+
168
+ function AutomationTrigger({ automation }: { automation: AutomationSummary }) {
169
+ const scheduled = automation.kind === "scheduled";
170
+ const Icon = scheduled ? CalendarClock : Zap;
171
+ let summary: string;
172
+ let detail: string;
173
+ let status: string | undefined;
174
+ let conditions: string | undefined;
175
+
176
+ if (automation.kind === "scheduled") {
177
+ summary = automation.schedule.includes(automation.timezone)
178
+ ? automation.schedule
179
+ : `${automation.schedule} (${automation.timezone})`;
180
+ status = automation.status === "active" ? undefined : automation.status;
181
+ detail = "No next run";
182
+ if (automation.status === "blocked") {
183
+ detail = "Future runs are blocked";
184
+ } else if (automation.status === "paused") {
185
+ detail = "Future triggers will not start work";
186
+ } else if (automation.status === "active" && automation.nextRunAt) {
187
+ const nextRun = new Date(automation.nextRunAt).toLocaleString(undefined, {
188
+ month: "short",
189
+ day: "numeric",
190
+ hour: "numeric",
191
+ minute: "2-digit",
192
+ timeZone: automation.timezone,
193
+ timeZoneName: "short",
194
+ });
195
+ detail = `Next ${nextRun}`;
196
+ }
197
+ } else {
198
+ summary = automation.resource;
199
+ detail = automation.events
200
+ .map((event) => event.replaceAll(/[._]/g, " "))
201
+ .join(", ");
202
+ status =
203
+ automation.status === "paused"
204
+ ? "paused"
205
+ : automation.triggerAvailable
206
+ ? undefined
207
+ : "Trigger unavailable";
208
+ conditions = Object.entries(automation.match ?? {})
209
+ .map(
210
+ ([field, value]) =>
211
+ `${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
212
+ )
213
+ .join(" · ");
214
+ }
215
+ return (
216
+ <div className="flex items-start gap-2">
217
+ <Icon
218
+ aria-label={
219
+ scheduled
220
+ ? "Scheduled automation"
221
+ : `${automation.source} event automation`
222
+ }
223
+ className="mt-0.5 shrink-0 text-dashboard-text-muted"
224
+ role="img"
225
+ size={16}
226
+ />
227
+ <div className="min-w-0">
228
+ <div className="break-words text-sm text-dashboard-text">{summary}</div>
229
+ <div className="mt-1 break-words text-xs text-dashboard-text-muted">
230
+ {detail}
231
+ </div>
232
+ {conditions ? (
233
+ <div className="mt-1 break-words text-xs text-dashboard-text-muted">
234
+ {conditions}
235
+ </div>
236
+ ) : null}
237
+ {status ? (
238
+ <StatusChip
239
+ className="mt-1 xl:mt-2"
240
+ size="compact"
241
+ tone={
242
+ status === "completed" || status === "paused"
243
+ ? "neutral"
244
+ : "warning"
245
+ }
246
+ >
247
+ {status}
248
+ </StatusChip>
249
+ ) : null}
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ function AutomationActions(props: {
256
+ editPath: string;
257
+ deleting: boolean;
258
+ onDelete(): void;
259
+ title: string;
260
+ }) {
261
+ const [open, setOpen] = useState(false);
262
+ const root = useRef<HTMLDivElement>(null);
263
+ const trigger = useRef<HTMLButtonElement>(null);
264
+ const item = useRef<HTMLButtonElement>(null);
265
+ const menuId = useId();
266
+
267
+ useEffect(() => {
268
+ if (!open) return;
269
+ item.current?.focus();
270
+ function closeOutside(event: PointerEvent) {
271
+ if (!root.current?.contains(event.target as Node)) setOpen(false);
272
+ }
273
+ document.addEventListener("pointerdown", closeOutside);
274
+ return () => document.removeEventListener("pointerdown", closeOutside);
275
+ }, [open]);
276
+
277
+ function close() {
278
+ setOpen(false);
279
+ trigger.current?.focus();
280
+ }
281
+
282
+ return (
283
+ <div
284
+ className="relative"
285
+ onBlur={(event) => {
286
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
287
+ }}
288
+ onKeyDown={(event) => {
289
+ if (event.key === "Escape") {
290
+ event.stopPropagation();
291
+ close();
292
+ } else if (
293
+ ["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
294
+ ) {
295
+ event.preventDefault();
296
+ setOpen(true);
297
+ const items = Array.from(
298
+ root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ??
299
+ [],
300
+ );
301
+ const index = items.indexOf(document.activeElement as HTMLElement);
302
+ const next =
303
+ event.key === "Home"
304
+ ? 0
305
+ : event.key === "End"
306
+ ? items.length - 1
307
+ : (index + (event.key === "ArrowUp" ? -1 : 1) + items.length) %
308
+ items.length;
309
+ items[next]?.focus();
310
+ }
311
+ }}
312
+ ref={root}
313
+ >
314
+ <button
315
+ aria-controls={open ? menuId : undefined}
316
+ aria-expanded={open}
317
+ aria-haspopup="menu"
318
+ aria-label={`Actions: ${props.title}`}
319
+ className="grid size-11 cursor-pointer place-items-center rounded-md border-0 bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus disabled:cursor-not-allowed disabled:opacity-50 xl:size-9"
320
+ disabled={props.deleting}
321
+ onClick={() => setOpen(!open)}
322
+ ref={trigger}
323
+ type="button"
324
+ >
325
+ <MoreHorizontal aria-hidden="true" size={18} />
326
+ </button>
327
+ {open ? (
328
+ <div
329
+ aria-label={`Actions: ${props.title}`}
330
+ className="absolute right-0 top-full z-20 min-w-36 rounded-md border border-dashboard-border bg-dashboard-surface-raised p-1 shadow-lg"
331
+ id={menuId}
332
+ onClick={(event) => event.stopPropagation()}
333
+ role="menu"
334
+ >
335
+ <Link
336
+ role="menuitem"
337
+ to={props.editPath}
338
+ className="flex min-h-11 items-center rounded px-3 text-sm text-dashboard-text no-underline hover:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-dashboard-focus"
339
+ >
340
+ Edit automation
341
+ </Link>
342
+ <button
343
+ className="flex min-h-11 w-full cursor-pointer items-center gap-2 rounded border-0 bg-transparent px-3 text-sm text-rose-300 hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
344
+ onClick={() => {
345
+ close();
346
+ props.onDelete();
347
+ }}
348
+ ref={item}
349
+ role="menuitem"
350
+ type="button"
351
+ >
352
+ <Trash2 aria-hidden="true" size={15} /> Delete
353
+ </button>
354
+ </div>
355
+ ) : null}
356
+ </div>
357
+ );
358
+ }
@@ -0,0 +1,328 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { CalendarClock } from "lucide-react";
4
+ import {
5
+ automationScheduleIntentSchema,
6
+ automationSchedulePreviewSchema,
7
+ type AutomationEdit,
8
+ } from "@sentry/junior/api/schema";
9
+ import { Field } from "../../components/Field";
10
+ import { Select } from "../../components/Select";
11
+ import { TextInput } from "../../components/TextInput";
12
+ import { Button } from "../../components/Button";
13
+ import { post, DashboardApiError } from "../../http";
14
+ import { cn } from "../../styles";
15
+ import {
16
+ scheduleDraft,
17
+ weekdays,
18
+ type AutomationScheduleDraft,
19
+ } from "./automationDraft";
20
+
21
+ /** Edit calendar fields and preview the next run without saving. */
22
+ export function AutomationScheduleFields(props: {
23
+ automation: Extract<AutomationEdit, { kind: "scheduled" }>;
24
+ value?: AutomationScheduleDraft;
25
+ onChange(value: AutomationScheduleDraft | undefined): void;
26
+ }) {
27
+ const value = props.value ?? scheduleDraft(props.automation);
28
+ const [previewValue, setPreviewValue] = useState(props.value);
29
+ useEffect(() => {
30
+ const timer = setTimeout(() => setPreviewValue(props.value), 300);
31
+ return () => clearTimeout(timer);
32
+ }, [props.value]);
33
+ const parsed = automationScheduleIntentSchema.safeParse(previewValue);
34
+ const preview = useQuery({
35
+ queryKey: [
36
+ "dashboard",
37
+ "automation-schedule-preview",
38
+ props.automation.id,
39
+ previewValue,
40
+ ],
41
+ enabled: Boolean(previewValue) && parsed.success,
42
+ retry: false,
43
+ queryFn: () =>
44
+ post(
45
+ automationSchedulePreviewSchema,
46
+ `/api/automations/scheduled/${encodeURIComponent(props.automation.id)}/preview`,
47
+ previewValue,
48
+ ),
49
+ });
50
+ const frequency = value.kind === "recurring" ? value.frequency : "once";
51
+ const time = value.kind === "recurring" ? value.time : value.timing.time;
52
+ const nextRun = props.value
53
+ ? preview.data?.nextRunAtMs
54
+ : props.automation.nextRunAtMs;
55
+ const waiting = props.value !== previewValue || preview.isFetching;
56
+ const issue =
57
+ preview.error instanceof DashboardApiError
58
+ ? preview.error.apiError
59
+ : preview.error
60
+ ? "Schedule preview could not be loaded. Try again."
61
+ : props.value && !parsed.success && !waiting
62
+ ? parsed.error.issues[0]?.message
63
+ : undefined;
64
+ return (
65
+ <>
66
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
67
+ <Field label="Repeat" htmlFor="schedule-frequency">
68
+ <Select
69
+ id="schedule-frequency"
70
+ value={frequency}
71
+ onChange={(e) => {
72
+ const frequency = e.target.value;
73
+ if (
74
+ frequency !== "once" &&
75
+ frequency !== "daily" &&
76
+ frequency !== "weekly" &&
77
+ frequency !== "monthly" &&
78
+ frequency !== "yearly"
79
+ )
80
+ return;
81
+ props.onChange(
82
+ frequency === "once"
83
+ ? {
84
+ kind: "one_off",
85
+ timezone:
86
+ value.timezone ?? props.automation.schedule.timezone,
87
+ timing: { type: "at", date: "", time },
88
+ }
89
+ : {
90
+ kind: "recurring",
91
+ timezone: value.timezone,
92
+ time,
93
+ frequency,
94
+ interval: 1,
95
+ weekdays: frequency === "weekly" ? ["monday"] : undefined,
96
+ dayOfMonth: ["monthly", "yearly"].includes(frequency)
97
+ ? 1
98
+ : undefined,
99
+ month: frequency === "yearly" ? 1 : undefined,
100
+ },
101
+ );
102
+ }}
103
+ >
104
+ <option value="once">Once</option>
105
+ <option value="daily">Every day</option>
106
+ <option value="weekly">Every week</option>
107
+ <option value="monthly">Every month</option>
108
+ <option value="yearly">Every year</option>
109
+ </Select>
110
+ </Field>
111
+ <Field label="Time" htmlFor="schedule-time">
112
+ <TextInput
113
+ size="comfortable"
114
+ id="schedule-time"
115
+ type="time"
116
+ value={time}
117
+ onChange={(e) =>
118
+ props.onChange(
119
+ value.kind === "recurring"
120
+ ? { ...value, time: e.target.value }
121
+ : {
122
+ ...value,
123
+ timing: {
124
+ type: "at",
125
+ date: value.timing.date,
126
+ time: e.target.value,
127
+ },
128
+ },
129
+ )
130
+ }
131
+ />
132
+ </Field>
133
+ </div>
134
+ {value.kind === "recurring" ? (
135
+ <>
136
+ {value.frequency === "weekly" ? (
137
+ <fieldset>
138
+ <legend className="mb-2 text-sm font-semibold">On</legend>
139
+ <div className="grid grid-cols-7 gap-1.5">
140
+ {[...weekdays.slice(1), weekdays[0]].map((day) => (
141
+ <button
142
+ type="button"
143
+ key={day}
144
+ aria-label={day}
145
+ aria-pressed={value.weekdays?.includes(day) ?? false}
146
+ onClick={() =>
147
+ props.onChange({
148
+ ...value,
149
+ weekdays: value.weekdays?.includes(day)
150
+ ? value.weekdays.filter((d) => d !== day)
151
+ : [...(value.weekdays ?? []), day],
152
+ })
153
+ }
154
+ className={cn(
155
+ "min-h-11 cursor-pointer rounded border px-1 text-xs font-medium capitalize focus-visible:outline-2 focus-visible:outline-dashboard-focus sm:min-h-10 sm:text-sm",
156
+ value.weekdays?.includes(day)
157
+ ? "border-dashboard-focus/50 bg-dashboard-focus/10 text-dashboard-focus"
158
+ : "border-dashboard-border-emphasis text-dashboard-text-muted hover:bg-dashboard-fill-hover",
159
+ )}
160
+ >
161
+ {day.slice(0, 3)}
162
+ </button>
163
+ ))}
164
+ </div>
165
+ </fieldset>
166
+ ) : null}
167
+ <div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
168
+ <Field
169
+ label="Interval"
170
+ htmlFor="schedule-interval"
171
+ help="Number of calendar periods between runs."
172
+ >
173
+ <TextInput
174
+ size="comfortable"
175
+ id="schedule-interval"
176
+ type="number"
177
+ min={1}
178
+ max={365}
179
+ value={value.interval ?? 1}
180
+ onChange={(e) =>
181
+ props.onChange({ ...value, interval: e.target.valueAsNumber })
182
+ }
183
+ />
184
+ </Field>
185
+ <Field label="Start date" htmlFor="schedule-start">
186
+ <TextInput
187
+ size="comfortable"
188
+ id="schedule-start"
189
+ type="date"
190
+ value={value.startDate ?? ""}
191
+ onChange={(e) =>
192
+ props.onChange({
193
+ ...value,
194
+ startDate: e.target.value || undefined,
195
+ })
196
+ }
197
+ />
198
+ </Field>
199
+ </div>
200
+ {value.frequency === "monthly" || value.frequency === "yearly" ? (
201
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
202
+ <Field label="Day of month" htmlFor="schedule-day">
203
+ <TextInput
204
+ size="comfortable"
205
+ id="schedule-day"
206
+ type="number"
207
+ min={1}
208
+ max={31}
209
+ value={value.dayOfMonth ?? 1}
210
+ onChange={(e) =>
211
+ props.onChange({
212
+ ...value,
213
+ dayOfMonth: e.target.valueAsNumber,
214
+ })
215
+ }
216
+ />
217
+ </Field>
218
+ {value.frequency === "yearly" ? (
219
+ <Field label="Month" htmlFor="schedule-month">
220
+ <Select
221
+ id="schedule-month"
222
+ value={value.month ?? 1}
223
+ onChange={(e) =>
224
+ props.onChange({
225
+ ...value,
226
+ month: Number(e.target.value),
227
+ })
228
+ }
229
+ >
230
+ {Array.from({ length: 12 }, (_, index) => (
231
+ <option key={index} value={index + 1}>
232
+ {new Intl.DateTimeFormat("en", {
233
+ month: "long",
234
+ timeZone: "UTC",
235
+ }).format(new Date(Date.UTC(2026, index, 1)))}
236
+ </option>
237
+ ))}
238
+ </Select>
239
+ </Field>
240
+ ) : null}
241
+ </div>
242
+ ) : null}
243
+ </>
244
+ ) : (
245
+ <Field label="Date" htmlFor="schedule-date">
246
+ <TextInput
247
+ size="comfortable"
248
+ id="schedule-date"
249
+ type="date"
250
+ value={value.timing.date}
251
+ onChange={(e) =>
252
+ props.onChange({
253
+ ...value,
254
+ timing: { type: "at", time, date: e.target.value },
255
+ })
256
+ }
257
+ />
258
+ </Field>
259
+ )}
260
+ <Field
261
+ label="Timezone"
262
+ htmlFor="schedule-timezone"
263
+ help="Use an IANA timezone. The schedule follows local time, including daylight saving time."
264
+ >
265
+ <TextInput
266
+ size="comfortable"
267
+ id="schedule-timezone"
268
+ list="automation-timezones"
269
+ value={value.timezone ?? ""}
270
+ onChange={(e) =>
271
+ props.onChange({ ...value, timezone: e.target.value })
272
+ }
273
+ />
274
+ <datalist id="automation-timezones">
275
+ {["UTC", ...Intl.supportedValuesOf("timeZone")].map((zone) => (
276
+ <option key={zone}>{zone}</option>
277
+ ))}
278
+ </datalist>
279
+ </Field>
280
+ <div
281
+ role="status"
282
+ className="flex gap-3 rounded-md bg-dashboard-fill-soft p-3 text-sm leading-relaxed"
283
+ >
284
+ <CalendarClock
285
+ aria-hidden
286
+ size={17}
287
+ className="mt-0.5 shrink-0 text-dashboard-text-muted"
288
+ />
289
+ <div>
290
+ {waiting ? (
291
+ "Checking schedule…"
292
+ ) : issue ? (
293
+ <>
294
+ <span className="text-rose-300">{issue}</span>
295
+ {preview.error ? (
296
+ <Button onClick={() => void preview.refetch()}>
297
+ Try again
298
+ </Button>
299
+ ) : null}
300
+ </>
301
+ ) : nextRun ? (
302
+ <>
303
+ Next run{" "}
304
+ {new Intl.DateTimeFormat(undefined, {
305
+ year: "numeric",
306
+ month: "short",
307
+ day: "numeric",
308
+ hour: "numeric",
309
+ minute: "2-digit",
310
+ timeZoneName: "short",
311
+ timeZone: props.value
312
+ ? (preview.data?.schedule.timezone ?? "UTC")
313
+ : props.automation.schedule.timezone,
314
+ }).format(nextRun)}
315
+ </>
316
+ ) : (
317
+ "No next run."
318
+ )}
319
+ </div>
320
+ </div>
321
+ {props.value ? (
322
+ <Button className="w-fit" onClick={() => props.onChange(undefined)}>
323
+ Keep saved schedule
324
+ </Button>
325
+ ) : null}
326
+ </>
327
+ );
328
+ }