@sentry/junior-dashboard 0.233.0 → 0.235.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 (59) hide show
  1. package/dist/app.js +178 -29
  2. package/dist/client.js +53 -50
  3. package/dist/index.js +178 -29
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/assets/junior-avatar.png +0 -0
  7. package/src/assets/junior-favicon.png +0 -0
  8. package/src/assets.ts +1 -0
  9. package/src/client/App.tsx +372 -345
  10. package/src/client/api.ts +60 -1
  11. package/src/client/components/ActorAvatar.tsx +37 -0
  12. package/src/client/components/Button.tsx +12 -10
  13. package/src/client/components/Field.tsx +14 -1
  14. package/src/client/components/FormNotice.tsx +40 -0
  15. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  16. package/src/client/components/README.md +19 -6
  17. package/src/client/components/SegmentedNav.tsx +167 -0
  18. package/src/client/components/SegmentedTabs.tsx +24 -5
  19. package/src/client/components/Select.tsx +18 -0
  20. package/src/client/components/StatusChip.tsx +15 -12
  21. package/src/client/components/TextInput.tsx +25 -5
  22. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
  25. package/src/client/pages/automations/AutomationEditPage.tsx +171 -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 +179 -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 +343 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +186 -439
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
  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/automationFormat.ts +43 -0
  40. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  41. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  42. package/src/client/pages/code/CodePage.tsx +18 -93
  43. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  44. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  45. package/src/client/pages/code/RepositorySections.tsx +108 -0
  46. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  47. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  48. package/src/client/pages/dev/ComponentsPage.tsx +55 -26
  49. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  50. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  51. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  52. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  53. package/src/client/searchParams.ts +4 -2
  54. package/src/client.tsx +7 -4
  55. package/src/mock-reporting/automation-collection.ts +101 -0
  56. package/src/mock-reporting/code-repository.ts +77 -0
  57. package/src/mock-reporting/fixtures.ts +9 -3
  58. package/src/mock-reporting/routes.ts +39 -17
  59. package/src/shell.ts +21 -5
@@ -0,0 +1,343 @@
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 { type ReactNode, 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, peoplePath } from "../../format";
15
+ import {
16
+ automationEventConditions,
17
+ automationEventNames,
18
+ automationScheduleLabel,
19
+ formatAutomationRunTime,
20
+ } from "./automationFormat";
21
+
22
+ /**
23
+ * Show one automation as a compact card: what it is, where it posts, who owns
24
+ * it, and when it runs. Instructions, timezones, and event conditions stay in
25
+ * the details drawer.
26
+ */
27
+ export function AutomationRow(props: {
28
+ deleting: boolean;
29
+ onDelete(): void;
30
+ onSelect(): void;
31
+ selected: boolean;
32
+ automation: AutomationSummary;
33
+ }) {
34
+ const { automation } = props;
35
+ const location = useLocation();
36
+ const status = automationStatus(automation);
37
+ const creator = (
38
+ <>
39
+ <ActorAvatar
40
+ imageUrl={automation.createdByAvatarUrl}
41
+ name={automation.createdBy}
42
+ size="list"
43
+ />
44
+ <span className="truncate">{automation.createdBy}</span>
45
+ </>
46
+ );
47
+ return (
48
+ <article
49
+ className="min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint"
50
+ role="listitem"
51
+ >
52
+ <SelectableRow
53
+ className="flex items-start gap-3 rounded-lg px-4 py-3.5"
54
+ onSelect={props.onSelect}
55
+ selected={props.selected}
56
+ >
57
+ <div className="min-w-0 flex-1">
58
+ <button
59
+ aria-expanded={props.selected}
60
+ aria-label={`View automation details: ${automation.title}`}
61
+ className="block w-full 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"
62
+ onClick={props.onSelect}
63
+ type="button"
64
+ >
65
+ <span className="flex min-w-0 items-center gap-2">
66
+ <span
67
+ className="truncate font-display text-base font-medium text-dashboard-text"
68
+ title={automation.title}
69
+ >
70
+ {automation.title}
71
+ </span>
72
+ {status ? (
73
+ <StatusChip
74
+ className="shrink-0"
75
+ size="compact"
76
+ tone={
77
+ status === "completed" || status === "paused"
78
+ ? "neutral"
79
+ : "warning"
80
+ }
81
+ >
82
+ {status}
83
+ </StatusChip>
84
+ ) : null}
85
+ </span>
86
+ <AutomationSummaryLine automation={automation} />
87
+ </button>
88
+ <div className="mt-2.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-dashboard-text-muted">
89
+ {automation.createdByEmail ? (
90
+ <Link
91
+ className="inline-flex min-w-0 max-w-full items-center gap-2 rounded text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
92
+ title={automation.createdBy}
93
+ to={peoplePath(automation.createdByEmail)}
94
+ >
95
+ {creator}
96
+ </Link>
97
+ ) : (
98
+ <span
99
+ className="inline-flex min-w-0 max-w-full items-center gap-2 text-dashboard-text"
100
+ title={automation.createdBy}
101
+ >
102
+ {creator}
103
+ </span>
104
+ )}
105
+ <AutomationTiming automation={automation} />
106
+ </div>
107
+ </div>
108
+ {automation.ownedByViewer ? (
109
+ <div className="-mr-2 -mt-1.5 shrink-0">
110
+ <AutomationActions
111
+ editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
112
+ deleting={props.deleting}
113
+ onDelete={props.onDelete}
114
+ title={automation.title}
115
+ />
116
+ </div>
117
+ ) : null}
118
+ </SelectableRow>
119
+ </article>
120
+ );
121
+ }
122
+
123
+ /** Return the one state worth flagging in the list, if the automation is not simply active. */
124
+ function automationStatus(automation: AutomationSummary): string | undefined {
125
+ if (automation.kind === "scheduled") {
126
+ return automation.status === "active" ? undefined : automation.status;
127
+ }
128
+ if (automation.status === "paused") return "paused";
129
+ return automation.triggerAvailable ? undefined : "Trigger unavailable";
130
+ }
131
+
132
+ /** Combine the trigger and Destination into one muted line under the title. */
133
+ function AutomationSummaryLine({
134
+ automation,
135
+ }: {
136
+ automation: AutomationSummary;
137
+ }) {
138
+ const scheduled = automation.kind === "scheduled";
139
+ const Icon = scheduled ? CalendarClock : Zap;
140
+ const trigger =
141
+ automation.kind === "scheduled"
142
+ ? automation.schedule
143
+ : `${automation.resource} · ${automationEventNames(automation)}`;
144
+ const conditions =
145
+ automation.kind === "event" ? automationEventConditions(automation) : "";
146
+ const triggerDetail =
147
+ automation.kind === "scheduled"
148
+ ? automationScheduleLabel(automation)
149
+ : conditions
150
+ ? `${trigger} · ${conditions}`
151
+ : trigger;
152
+ return (
153
+ <span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-dashboard-text-muted">
154
+ <Icon
155
+ aria-label={
156
+ scheduled
157
+ ? "Scheduled automation"
158
+ : `${automation.source} event automation`
159
+ }
160
+ className="shrink-0"
161
+ role="img"
162
+ size={13}
163
+ />
164
+ <span className="min-w-0 truncate" title={triggerDetail}>
165
+ {trigger}
166
+ </span>
167
+ <span aria-hidden="true" className="shrink-0 opacity-45">
168
+ ·
169
+ </span>
170
+ {automation.destination.visibility === "private" ? (
171
+ <LockKeyhole
172
+ aria-label="Private destination"
173
+ className="shrink-0"
174
+ role="img"
175
+ size={12}
176
+ />
177
+ ) : null}
178
+ <span
179
+ className="min-w-0 max-w-[45%] truncate"
180
+ title={automation.destination.label}
181
+ >
182
+ {automation.destination.label}
183
+ </span>
184
+ </span>
185
+ );
186
+ }
187
+
188
+ /** Show next and last run as short relative times after the creator. */
189
+ function AutomationTiming({ automation }: { automation: AutomationSummary }) {
190
+ const nextRunAt =
191
+ automation.kind === "scheduled" && automation.status === "active"
192
+ ? automation.nextRunAt
193
+ : undefined;
194
+ const lastRunTitle = automation.lastRunAt
195
+ ? formatAutomationRunTime(automation.lastRunAt)
196
+ : undefined;
197
+ const lastRunFailed =
198
+ automation.lastRunStatus === "failed" ||
199
+ automation.lastRunStatus === "blocked";
200
+ return (
201
+ <>
202
+ {nextRunAt ? (
203
+ <MetaItem title={formatAutomationRunTime(nextRunAt)}>
204
+ Next run {formatRelativeTime(nextRunAt)}
205
+ </MetaItem>
206
+ ) : null}
207
+ <MetaItem title={lastRunTitle}>
208
+ {lastRunFailed ? (
209
+ <Link
210
+ className="text-amber-300 underline"
211
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
212
+ >
213
+ Last run {automation.lastRunStatus}
214
+ {automation.lastRunAt
215
+ ? ` ${formatRelativeTime(automation.lastRunAt)}`
216
+ : null}
217
+ </Link>
218
+ ) : automation.lastRunAt ? (
219
+ `Last run ${formatRelativeTime(automation.lastRunAt)}`
220
+ ) : (
221
+ "Never run"
222
+ )}
223
+ </MetaItem>
224
+ </>
225
+ );
226
+ }
227
+
228
+ /** Keep each separator with the item after it so wrapped lines never end in a dot. */
229
+ function MetaItem(props: { children: ReactNode; title?: string }) {
230
+ return (
231
+ <span className="whitespace-nowrap" title={props.title}>
232
+ <span aria-hidden="true" className="mr-2 opacity-45">
233
+ ·
234
+ </span>
235
+ {props.children}
236
+ </span>
237
+ );
238
+ }
239
+
240
+ function AutomationActions(props: {
241
+ editPath: string;
242
+ deleting: boolean;
243
+ onDelete(): void;
244
+ title: string;
245
+ }) {
246
+ const [open, setOpen] = useState(false);
247
+ const root = useRef<HTMLDivElement>(null);
248
+ const trigger = useRef<HTMLButtonElement>(null);
249
+ const item = useRef<HTMLButtonElement>(null);
250
+ const menuId = useId();
251
+
252
+ useEffect(() => {
253
+ if (!open) return;
254
+ item.current?.focus();
255
+ function closeOutside(event: PointerEvent) {
256
+ if (!root.current?.contains(event.target as Node)) setOpen(false);
257
+ }
258
+ document.addEventListener("pointerdown", closeOutside);
259
+ return () => document.removeEventListener("pointerdown", closeOutside);
260
+ }, [open]);
261
+
262
+ function close() {
263
+ setOpen(false);
264
+ trigger.current?.focus();
265
+ }
266
+
267
+ return (
268
+ <div
269
+ className="relative"
270
+ onBlur={(event) => {
271
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
272
+ }}
273
+ onKeyDown={(event) => {
274
+ if (event.key === "Escape") {
275
+ event.stopPropagation();
276
+ close();
277
+ } else if (
278
+ ["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
279
+ ) {
280
+ event.preventDefault();
281
+ setOpen(true);
282
+ const items = Array.from(
283
+ root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ??
284
+ [],
285
+ );
286
+ const index = items.indexOf(document.activeElement as HTMLElement);
287
+ const next =
288
+ event.key === "Home"
289
+ ? 0
290
+ : event.key === "End"
291
+ ? items.length - 1
292
+ : (index + (event.key === "ArrowUp" ? -1 : 1) + items.length) %
293
+ items.length;
294
+ items[next]?.focus();
295
+ }
296
+ }}
297
+ ref={root}
298
+ >
299
+ <button
300
+ aria-controls={open ? menuId : undefined}
301
+ aria-expanded={open}
302
+ aria-haspopup="menu"
303
+ aria-label={`Actions: ${props.title}`}
304
+ 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"
305
+ disabled={props.deleting}
306
+ onClick={() => setOpen(!open)}
307
+ ref={trigger}
308
+ type="button"
309
+ >
310
+ <MoreHorizontal aria-hidden="true" size={18} />
311
+ </button>
312
+ {open ? (
313
+ <div
314
+ aria-label={`Actions: ${props.title}`}
315
+ 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"
316
+ id={menuId}
317
+ onClick={(event) => event.stopPropagation()}
318
+ role="menu"
319
+ >
320
+ <Link
321
+ role="menuitem"
322
+ to={props.editPath}
323
+ 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"
324
+ >
325
+ Edit automation
326
+ </Link>
327
+ <button
328
+ 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"
329
+ onClick={() => {
330
+ close();
331
+ props.onDelete();
332
+ }}
333
+ ref={item}
334
+ role="menuitem"
335
+ type="button"
336
+ >
337
+ <Trash2 aria-hidden="true" size={15} /> Delete
338
+ </button>
339
+ </div>
340
+ ) : null}
341
+ </div>
342
+ );
343
+ }
@@ -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
+ }