@sentry/junior-dashboard 0.266.0 → 0.267.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.
- package/dist/app.js +49 -12
- package/dist/client.js +94 -63
- package/dist/index.js +49 -12
- package/dist/tailwind.css +1 -1
- package/package.json +11 -5
- package/src/app.ts +0 -2
- package/src/auth.ts +4 -7
- package/src/client/App.tsx +35 -53
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/ImageAttachment.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +4 -1
- package/src/client/components/README.md +11 -2
- package/src/client/components/ui/alert.tsx +1 -1
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/dialog.tsx +29 -35
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +30 -1
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationComposer.tsx +84 -12
- package/src/client/conversations/ConversationDetails.tsx +3 -27
- package/src/client/conversations/ConversationEventDetails.tsx +26 -10
- package/src/client/conversations/ConversationPage.tsx +156 -2
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/DetailsList.tsx +33 -0
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +310 -112
- package/src/client/conversations/README.md +30 -13
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
- package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
- package/src/client/conversations/TranscriptAttachment.tsx +39 -4
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageView.tsx +7 -1
- package/src/client/conversations/TranscriptSummary.tsx +5 -31
- package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
- package/src/client/conversations/TranscriptView.tsx +7 -3
- package/src/client/conversations/UserInputRequestCard.tsx +244 -0
- package/src/client/conversations/conversationOutbox.ts +46 -0
- package/src/client/conversations/eventTime.ts +13 -1
- package/src/client/conversations/eventTranscript.ts +12 -5
- package/src/client/conversations/queries.ts +127 -26
- package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
- package/src/client/conversations/userInputRequest.ts +56 -0
- package/src/client/format.ts +10 -3
- package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
- package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +182 -156
- package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
- package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
- package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
- package/src/client/pages/automations/AutomationOverview.tsx +238 -0
- package/src/client/pages/automations/AutomationPage.tsx +343 -0
- package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
- package/src/client/pages/automations/AutomationRow.tsx +11 -13
- package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
- package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
- package/src/client/pages/automations/AutomationStatus.tsx +164 -108
- package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
- package/src/client/pages/automations/AutomationsPage.tsx +10 -98
- package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
- package/src/client/pages/automations/README.md +56 -46
- package/src/client/types.ts +3 -0
- package/src/mock-reporting/fixtures.ts +44 -2
- package/src/client/conversations/transcriptStyles.ts +0 -10
- package/src/client/pages/automations/AutomationDetails.tsx +0 -193
- package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
- package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
package/src/client/format.ts
CHANGED
|
@@ -796,9 +796,16 @@ export function workspacePath(workspaceId: string): string {
|
|
|
796
796
|
return `/system/workspaces/${encodeURIComponent(workspaceId)}`;
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
-
/**
|
|
800
|
-
export
|
|
801
|
-
|
|
799
|
+
/** Automation page tabs. Overview is the bare Automation path. */
|
|
800
|
+
export type AutomationTab = "overview" | "runs" | "versions" | "edit";
|
|
801
|
+
|
|
802
|
+
/** Build the route for one Automation page tab, or its edit mode. */
|
|
803
|
+
export function automationPath(
|
|
804
|
+
automationId: string,
|
|
805
|
+
tab: AutomationTab = "overview",
|
|
806
|
+
): string {
|
|
807
|
+
const base = `/automations/${encodeURIComponent(automationId)}`;
|
|
808
|
+
return tab === "overview" ? base : `${base}/${tab}`;
|
|
802
809
|
}
|
|
803
810
|
|
|
804
811
|
function normalizeLanguage(language: string | undefined): BundledLanguage {
|
|
@@ -12,23 +12,30 @@ import {
|
|
|
12
12
|
TooltipTrigger,
|
|
13
13
|
} from "../../components/ui/tooltip";
|
|
14
14
|
import { Link } from "react-router";
|
|
15
|
-
import { CircleHelp, Pencil,
|
|
15
|
+
import { CircleHelp, Pencil, Trash2 } from "lucide-react";
|
|
16
16
|
import {
|
|
17
17
|
automationEditSchema,
|
|
18
18
|
type AutomationSummary,
|
|
19
19
|
} from "@sentry/junior/api/schema";
|
|
20
|
-
import { Button } from "../../components/ui/button";
|
|
20
|
+
import { Button, buttonVariants } from "../../components/ui/button";
|
|
21
21
|
import { FormNotice } from "../../components/FormNotice";
|
|
22
22
|
import { DashboardApiError, fetchDashboardJson, post } from "../../http";
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Header actions for one Automation: Edit, then creator-only Pause or Resume
|
|
26
|
+
* and Delete. Public readers can edit settings, but only the creator controls
|
|
27
|
+
* lifecycle. Lifecycle actions re-read the revision before they apply.
|
|
28
|
+
*/
|
|
29
|
+
export function AutomationActions({
|
|
26
30
|
automation,
|
|
27
31
|
editPath,
|
|
32
|
+
editVariant,
|
|
28
33
|
onDelete,
|
|
29
34
|
}: {
|
|
30
35
|
automation: AutomationSummary;
|
|
31
36
|
editPath: string;
|
|
37
|
+
/** Use "default" when a notice on the page owns the main action. */
|
|
38
|
+
editVariant: "default" | "primary";
|
|
32
39
|
onDelete(source: HTMLButtonElement): void;
|
|
33
40
|
}) {
|
|
34
41
|
const toast = useToastManager();
|
|
@@ -36,8 +43,6 @@ export function AutomationLifecycle({
|
|
|
36
43
|
const completed = automation.status === "completed";
|
|
37
44
|
const paused = automation.status === "paused";
|
|
38
45
|
const blocked = automation.status === "blocked";
|
|
39
|
-
// Public readers can edit settings, but only the creator controls lifecycle.
|
|
40
|
-
const canEdit = !completed;
|
|
41
46
|
const canPause = automation.ownedByViewer && !completed;
|
|
42
47
|
const action = paused || blocked ? "resume" : "pause";
|
|
43
48
|
const mutation = useMutation({
|
|
@@ -50,7 +55,7 @@ export function AutomationLifecycle({
|
|
|
50
55
|
// Do not apply the opposite action if another surface changed lifecycle.
|
|
51
56
|
if (current.status !== automation.status) {
|
|
52
57
|
throw new Error(
|
|
53
|
-
"The Automation changed.
|
|
58
|
+
"The Automation changed. Reload the page before trying again.",
|
|
54
59
|
);
|
|
55
60
|
}
|
|
56
61
|
return post(automationEditSchema, `${path}/lifecycle`, {
|
|
@@ -80,57 +85,24 @@ export function AutomationLifecycle({
|
|
|
80
85
|
},
|
|
81
86
|
});
|
|
82
87
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<Pencil aria-hidden="true" size={16} />
|
|
99
|
-
) : (
|
|
100
|
-
<Settings2 aria-hidden="true" size={16} />
|
|
101
|
-
)}
|
|
102
|
-
</Link>
|
|
103
|
-
}
|
|
104
|
-
/>
|
|
105
|
-
<TooltipContent size="compact">
|
|
106
|
-
{canEdit ? "Edit automation" : "View settings"}
|
|
107
|
-
</TooltipContent>
|
|
108
|
-
</Tooltip>
|
|
109
|
-
{automation.ownedByViewer ? (
|
|
110
|
-
<Tooltip>
|
|
111
|
-
<TooltipTrigger
|
|
112
|
-
render={
|
|
113
|
-
<Button
|
|
114
|
-
focusableWhenDisabled
|
|
115
|
-
aria-label="Delete automation"
|
|
116
|
-
className="min-h-11 min-w-11"
|
|
117
|
-
size="icon"
|
|
118
|
-
variant="danger"
|
|
119
|
-
onClick={(event) => onDelete(event.currentTarget)}
|
|
120
|
-
disabled={mutation.isPending}
|
|
121
|
-
>
|
|
122
|
-
<Trash2 aria-hidden="true" size={16} />
|
|
123
|
-
</Button>
|
|
124
|
-
}
|
|
125
|
-
/>
|
|
126
|
-
<TooltipContent size="compact">Delete automation</TooltipContent>
|
|
127
|
-
</Tooltip>
|
|
128
|
-
) : null}
|
|
129
|
-
</div>
|
|
88
|
+
<div className="grid min-w-0 justify-items-stretch gap-3 md:justify-items-end">
|
|
89
|
+
<div
|
|
90
|
+
aria-label="Automation actions"
|
|
91
|
+
className="flex flex-wrap items-center gap-2"
|
|
92
|
+
role="group"
|
|
93
|
+
>
|
|
94
|
+
{completed ? null : (
|
|
95
|
+
<Link
|
|
96
|
+
className={buttonVariants({ variant: editVariant })}
|
|
97
|
+
to={editPath}
|
|
98
|
+
>
|
|
99
|
+
<Pencil aria-hidden="true" size={14} />
|
|
100
|
+
Edit automation
|
|
101
|
+
</Link>
|
|
102
|
+
)}
|
|
130
103
|
{canPause ? (
|
|
131
104
|
<>
|
|
132
105
|
<Button
|
|
133
|
-
className="min-h-11"
|
|
134
106
|
disabled={mutation.isPending}
|
|
135
107
|
onClick={() => mutation.mutate()}
|
|
136
108
|
>
|
|
@@ -142,17 +114,15 @@ export function AutomationLifecycle({
|
|
|
142
114
|
</Button>
|
|
143
115
|
<Popover>
|
|
144
116
|
<PopoverTrigger
|
|
145
|
-
render={
|
|
146
|
-
<Button
|
|
147
|
-
aria-label="About pausing"
|
|
148
|
-
className="min-h-11 min-w-11"
|
|
149
|
-
size="icon"
|
|
150
|
-
/>
|
|
151
|
-
}
|
|
117
|
+
render={<Button aria-label="About pausing" size="icon" />}
|
|
152
118
|
>
|
|
153
119
|
<CircleHelp aria-hidden="true" size={16} />
|
|
154
120
|
</PopoverTrigger>
|
|
155
|
-
<PopoverContent
|
|
121
|
+
<PopoverContent
|
|
122
|
+
side="bottom"
|
|
123
|
+
align="end"
|
|
124
|
+
aria-label="About pausing"
|
|
125
|
+
>
|
|
156
126
|
<PopoverDescription>
|
|
157
127
|
Pausing stops new runs. Work already queued or running may
|
|
158
128
|
finish. Resuming skips missed runs and waits for the next
|
|
@@ -165,14 +135,35 @@ export function AutomationLifecycle({
|
|
|
165
135
|
</Popover>
|
|
166
136
|
</>
|
|
167
137
|
) : null}
|
|
138
|
+
{automation.ownedByViewer ? (
|
|
139
|
+
<Tooltip>
|
|
140
|
+
<TooltipTrigger
|
|
141
|
+
render={
|
|
142
|
+
<Button
|
|
143
|
+
focusableWhenDisabled
|
|
144
|
+
aria-label="Delete automation"
|
|
145
|
+
size="icon"
|
|
146
|
+
variant="danger"
|
|
147
|
+
onClick={(event) => onDelete(event.currentTarget)}
|
|
148
|
+
disabled={mutation.isPending}
|
|
149
|
+
>
|
|
150
|
+
<Trash2 aria-hidden="true" size={16} />
|
|
151
|
+
</Button>
|
|
152
|
+
}
|
|
153
|
+
/>
|
|
154
|
+
<TooltipContent size="compact">Delete automation</TooltipContent>
|
|
155
|
+
</Tooltip>
|
|
156
|
+
) : null}
|
|
168
157
|
</div>
|
|
169
158
|
{mutation.error ? (
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
159
|
+
<div className="max-w-sm">
|
|
160
|
+
<FormNotice tone="error" title="Could not update this automation.">
|
|
161
|
+
{mutation.error instanceof DashboardApiError
|
|
162
|
+
? (mutation.error.apiError ?? "Try again.")
|
|
163
|
+
: mutation.error.message}
|
|
164
|
+
</FormNotice>
|
|
165
|
+
</div>
|
|
175
166
|
) : null}
|
|
176
|
-
</
|
|
167
|
+
</div>
|
|
177
168
|
);
|
|
178
169
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutomationEdit,
|
|
3
|
+
AutomationSummary,
|
|
4
|
+
} from "@sentry/junior/api/schema";
|
|
5
|
+
import { Button } from "../../components/ui/button";
|
|
6
|
+
import { Card } from "../../components/layout/Card";
|
|
7
|
+
import { cn } from "../../styles";
|
|
8
|
+
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
9
|
+
import {
|
|
10
|
+
createAutomationDraft,
|
|
11
|
+
scheduleDraft,
|
|
12
|
+
type AutomationDraft,
|
|
13
|
+
type AutomationScheduleDraft,
|
|
14
|
+
} from "./automationDraft";
|
|
15
|
+
|
|
16
|
+
/** In form order, so the list reads top to bottom like the form. */
|
|
17
|
+
const CHANGE_LABELS: Record<keyof AutomationDraft, string> = {
|
|
18
|
+
title: "Title",
|
|
19
|
+
instruction: "Instruction",
|
|
20
|
+
schedule: "Schedule",
|
|
21
|
+
trigger: "Trigger",
|
|
22
|
+
outcomes: "Where results go",
|
|
23
|
+
credentialMode: "Credentials",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List unsaved fields with the saved value, the new value, and an Undo.
|
|
28
|
+
* It sits beside the form on wide screens and below it on small screens.
|
|
29
|
+
*/
|
|
30
|
+
export function AutomationDraftChanges(props: {
|
|
31
|
+
changes: Partial<AutomationDraft>;
|
|
32
|
+
disabled: boolean;
|
|
33
|
+
onUndo(key: keyof AutomationDraft): void;
|
|
34
|
+
original: AutomationEdit;
|
|
35
|
+
summary: AutomationSummary;
|
|
36
|
+
}) {
|
|
37
|
+
const keys = (Object.keys(CHANGE_LABELS) as (keyof AutomationDraft)[]).filter(
|
|
38
|
+
(key) => props.changes[key] !== undefined,
|
|
39
|
+
);
|
|
40
|
+
const saved = savedDraft(props.original);
|
|
41
|
+
const label = (draft: Partial<AutomationDraft>, key: keyof AutomationDraft) =>
|
|
42
|
+
draftValueLabel(draft, key, props.summary);
|
|
43
|
+
return (
|
|
44
|
+
<aside
|
|
45
|
+
aria-label="Your changes"
|
|
46
|
+
className={cn(
|
|
47
|
+
"min-w-0 lg:sticky lg:top-4 lg:self-start",
|
|
48
|
+
keys.length === 0 && "max-lg:hidden",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<Card padding="sm">
|
|
52
|
+
<h2 className="m-0 mb-3 font-sans text-xs font-medium text-dashboard-text-muted">
|
|
53
|
+
Your changes
|
|
54
|
+
</h2>
|
|
55
|
+
{keys.length === 0 ? (
|
|
56
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
57
|
+
No changes yet. Edit a section to see it here.
|
|
58
|
+
</p>
|
|
59
|
+
) : (
|
|
60
|
+
<ul className="m-0 grid list-none gap-3 p-0">
|
|
61
|
+
{keys.map((key) => (
|
|
62
|
+
<li className="grid min-w-0 gap-1 text-sm" key={key}>
|
|
63
|
+
<div className="flex items-center justify-between gap-3">
|
|
64
|
+
<span className="font-medium">{CHANGE_LABELS[key]}</span>
|
|
65
|
+
<Button
|
|
66
|
+
aria-label={`Undo ${CHANGE_LABELS[key].toLowerCase()} change`}
|
|
67
|
+
disabled={props.disabled}
|
|
68
|
+
onClick={() => props.onUndo(key)}
|
|
69
|
+
>
|
|
70
|
+
Undo
|
|
71
|
+
</Button>
|
|
72
|
+
</div>
|
|
73
|
+
<p className="m-0 line-clamp-3 break-words whitespace-pre-wrap text-xs text-dashboard-text-muted line-through decoration-dashboard-text-muted/50">
|
|
74
|
+
<span className="sr-only">Saved: </span>
|
|
75
|
+
{label(saved, key)}
|
|
76
|
+
</p>
|
|
77
|
+
<p className="m-0 line-clamp-3 break-words whitespace-pre-wrap text-xs">
|
|
78
|
+
<span className="sr-only">New: </span>
|
|
79
|
+
{label(props.changes, key)}
|
|
80
|
+
</p>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
)}
|
|
85
|
+
<p className="mt-3 mb-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
86
|
+
Saving creates a new version. It does not run this automation.
|
|
87
|
+
</p>
|
|
88
|
+
</Card>
|
|
89
|
+
</aside>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The saved values in draft form, including the saved schedule. */
|
|
94
|
+
function savedDraft(original: AutomationEdit): AutomationDraft {
|
|
95
|
+
const draft = createAutomationDraft(original);
|
|
96
|
+
return original.kind === "scheduled"
|
|
97
|
+
? { ...draft, schedule: scheduleDraft(original) }
|
|
98
|
+
: draft;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function draftValueLabel(
|
|
102
|
+
draft: Partial<AutomationDraft>,
|
|
103
|
+
key: keyof AutomationDraft,
|
|
104
|
+
summary: AutomationSummary,
|
|
105
|
+
): string {
|
|
106
|
+
switch (key) {
|
|
107
|
+
case "title":
|
|
108
|
+
return draft.title || "First line of the instruction";
|
|
109
|
+
case "instruction":
|
|
110
|
+
return draft.instruction || "Empty";
|
|
111
|
+
case "credentialMode":
|
|
112
|
+
return draft.credentialMode === "creator"
|
|
113
|
+
? summary.ownedByViewer
|
|
114
|
+
? "Your connected accounts"
|
|
115
|
+
: `${summary.createdBy}'s connected accounts`
|
|
116
|
+
: "System credentials only";
|
|
117
|
+
case "outcomes":
|
|
118
|
+
return draft.outcomes?.length
|
|
119
|
+
? draft.outcomes
|
|
120
|
+
.map((outcome) =>
|
|
121
|
+
automationOutcomeLabel(outcome, summary.destination),
|
|
122
|
+
)
|
|
123
|
+
.join(", ")
|
|
124
|
+
: "No success message";
|
|
125
|
+
case "schedule":
|
|
126
|
+
return draft.schedule ? scheduleDraftLabel(draft.schedule) : "Not set";
|
|
127
|
+
case "trigger": {
|
|
128
|
+
const trigger = draft.trigger;
|
|
129
|
+
if (!trigger) return "Not set";
|
|
130
|
+
const resource = trigger.identifier
|
|
131
|
+
? `${trigger.label} · ${trigger.identifier}`
|
|
132
|
+
: `Any ${trigger.label}`;
|
|
133
|
+
const filters = Object.keys(trigger.match ?? {}).length;
|
|
134
|
+
return [
|
|
135
|
+
resource,
|
|
136
|
+
trigger.events.join(", "),
|
|
137
|
+
filters ? `${filters} ${filters === 1 ? "filter" : "filters"}` : "",
|
|
138
|
+
]
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.join(" · ");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const FREQUENCY_UNITS = {
|
|
146
|
+
daily: "day",
|
|
147
|
+
weekly: "week",
|
|
148
|
+
monthly: "month",
|
|
149
|
+
yearly: "year",
|
|
150
|
+
} as const;
|
|
151
|
+
|
|
152
|
+
/** Describe a schedule draft. Saved and new values use the same words. */
|
|
153
|
+
function scheduleDraftLabel(schedule: AutomationScheduleDraft): string {
|
|
154
|
+
if (schedule.kind === "one_off") {
|
|
155
|
+
return `Once on ${schedule.timing.date} at ${schedule.timing.time} (${schedule.timezone})`;
|
|
156
|
+
}
|
|
157
|
+
const interval = schedule.interval ?? 1;
|
|
158
|
+
const unit = FREQUENCY_UNITS[schedule.frequency];
|
|
159
|
+
const cadence = interval === 1 ? unit : `${interval} ${unit}s`;
|
|
160
|
+
const detail =
|
|
161
|
+
schedule.frequency === "weekly" && schedule.weekdays?.length
|
|
162
|
+
? ` on ${schedule.weekdays.map((day) => day.slice(0, 1).toUpperCase() + day.slice(1, 3)).join(", ")}`
|
|
163
|
+
: schedule.frequency === "monthly" && schedule.dayOfMonth
|
|
164
|
+
? ` on day ${schedule.dayOfMonth}`
|
|
165
|
+
: schedule.frequency === "yearly" &&
|
|
166
|
+
schedule.month &&
|
|
167
|
+
schedule.dayOfMonth
|
|
168
|
+
? ` on ${String(schedule.month).padStart(2, "0")}-${String(schedule.dayOfMonth).padStart(2, "0")}`
|
|
169
|
+
: "";
|
|
170
|
+
return `Every ${cadence}${detail} at ${schedule.time} (${schedule.timezone})`;
|
|
171
|
+
}
|