Skip to content

Commit 1fdfa01

Browse files
committed
improve imperative schedule ux
1 parent 75080c1 commit 1fdfa01

4 files changed

Lines changed: 99 additions & 30 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,7 @@ function ScheduledTaskDetailSidebar({
867867
</Property.Value>
868868
</Property.Item>
869869
<Property.Item>
870-
<Property.Label>CRON</Property.Label>
870+
<Property.Label>Cron</Property.Label>
871871
<Property.Value>
872872
{firstSchedule ? (
873873
<div className="space-y-2">
@@ -879,16 +879,6 @@ function ScheduledTaskDetailSidebar({
879879
)}
880880
</Property.Value>
881881
</Property.Item>
882-
<Property.Item>
883-
<Property.Label>Window</Property.Label>
884-
<Property.Value>
885-
{firstSchedule ? (
886-
(firstSchedule.window ?? "Default (60 seconds)")
887-
) : (
888-
<span className="text-text-dimmed"></span>
889-
)}
890-
</Property.Value>
891-
</Property.Item>
892882
<Property.Item>
893883
<Property.Label>Created</Property.Label>
894884
<Property.Value>
@@ -1020,7 +1010,7 @@ function SchedulesMiniTable({
10201010
<TableRow>
10211011
<TableHeaderCell>Schedule ID</TableHeaderCell>
10221012
<TableHeaderCell>Type</TableHeaderCell>
1023-
<TableHeaderCell>CRON</TableHeaderCell>
1013+
<TableHeaderCell>Cron</TableHeaderCell>
10241014
<TableHeaderCell>Window</TableHeaderCell>
10251015
<TableHeaderCell>External ID</TableHeaderCell>
10261016
<TableHeaderCell>Next run</TableHeaderCell>
@@ -1049,7 +1039,7 @@ function SchedulesMiniTable({
10491039
<span className="font-mono text-xs">{schedule.cron}</span>
10501040
</TableCell>
10511041
<TableCell onClick={open}>
1052-
<span className="text-xs">{schedule.window ?? "Default (60s)"}</span>
1042+
<span className="text-xs">{schedule.window}</span>
10531043
</TableCell>
10541044
<TableCell onClick={open}>
10551045
{schedule.externalId ? (

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
22
import { parseWithZod } from "@conform-to/zod";
3+
import { ScheduleWindow } from "@trigger.dev/core/v3";
34
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
45
import {
56
type FetcherWithComponents,
@@ -150,6 +151,15 @@ type CronPatternResult =
150151
error: string;
151152
};
152153

154+
type ScheduleWindowResult =
155+
| {
156+
isValid: true;
157+
}
158+
| {
159+
isValid: false;
160+
error: string;
161+
};
162+
153163
export function UpsertScheduleForm({
154164
schedule,
155165
possibleTasks,
@@ -179,6 +189,7 @@ export function UpsertScheduleForm({
179189
const [selectedTimezone, setSelectedTimezone] = useState<string>(schedule?.timezone ?? "UTC");
180190
const isUtc = selectedTimezone === "UTC";
181191
const [cronPattern, setCronPattern] = useState<string>(schedule?.cron ?? "");
192+
const [scheduleWindowValue, setScheduleWindowValue] = useState<string>(schedule?.window ?? "");
182193
const navigation = useNavigation();
183194
const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle";
184195
const organization = useOrganization();
@@ -210,8 +221,16 @@ export function UpsertScheduleForm({
210221
});
211222

212223
let cronPatternResult: CronPatternResult | undefined = undefined;
224+
let scheduleWindowResult: ScheduleWindowResult | undefined = undefined;
213225
let nextRuns: Date[] | undefined = undefined;
214226

227+
if (scheduleWindowValue !== "") {
228+
const result = ScheduleWindow.safeParse(scheduleWindowValue);
229+
scheduleWindowResult = result.success
230+
? { isValid: true }
231+
: { isValid: false, error: result.error.errors[0].message };
232+
}
233+
215234
if (cronPattern !== "") {
216235
const result = CronPattern.safeParse(cronPattern);
217236

@@ -327,9 +346,19 @@ export function UpsertScheduleForm({
327346
{cronPatternResult === undefined ? (
328347
<Hint>Enter a CRON pattern or use natural language above.</Hint>
329348
) : cronPatternResult.isValid ? (
330-
<ValidCronMessage isValid={true} message={`${cronPatternResult.description}.`} />
349+
<ValidationMessage
350+
isValid={true}
351+
validLabel="Valid pattern:"
352+
invalidLabel="Invalid pattern:"
353+
message={`${cronPatternResult.description}.`}
354+
/>
331355
) : (
332-
<ValidCronMessage isValid={false} message={cronPatternResult.error} />
356+
<ValidationMessage
357+
isValid={false}
358+
validLabel="Valid pattern:"
359+
invalidLabel="Invalid pattern:"
360+
message={cronPatternResult.error}
361+
/>
333362
)}
334363
</InputGroup>
335364
<InputGroup>
@@ -364,19 +393,45 @@ export function UpsertScheduleForm({
364393
<Input
365394
{...getInputProps(scheduleWindow, { type: "text" })}
366395
placeholder="30m or 25%"
367-
defaultValue={schedule?.window}
396+
value={scheduleWindowValue}
397+
aria-invalid={scheduleWindowResult?.isValid === false ? true : undefined}
398+
aria-describedby={
399+
scheduleWindowResult === undefined ? undefined : scheduleWindow.errorId
400+
}
401+
onChange={(event) => setScheduleWindowValue(event.target.value)}
368402
/>
369-
<Hint>
370-
Assigns each run a stable time after its CRON time. Use minutes, hours, or a
371-
percentage of the interval. Schedules always use at least a 60-second placement
372-
range.
373-
</Hint>
374-
<FormError id={scheduleWindow.errorId}>{scheduleWindow.errors}</FormError>
403+
{scheduleWindowResult === undefined ? (
404+
<Hint>
405+
Assigns each run a stable time after its CRON time. Use minutes, hours, or a
406+
percentage of the interval.
407+
</Hint>
408+
) : scheduleWindowResult.isValid ? (
409+
<ValidationMessage
410+
id={scheduleWindow.errorId}
411+
isValid={true}
412+
validLabel="Valid window:"
413+
invalidLabel="Invalid window:"
414+
message="Runs will be assigned a stable time within this window."
415+
/>
416+
) : (
417+
<ValidationMessage
418+
id={scheduleWindow.errorId}
419+
isValid={false}
420+
validLabel="Valid window:"
421+
invalidLabel="Invalid window:"
422+
message={scheduleWindowResult.error}
423+
/>
424+
)}
375425
</InputGroup>
376426
{nextRuns !== undefined && (
377427
<div className="flex flex-col gap-1">
378-
<Header3>Next 5 CRON times</Header3>
379-
<Hint>Assigned times are calculated after the schedule is saved.</Hint>
428+
<Header3>Next 5 runs</Header3>
429+
{scheduleWindowValue !== "" && (
430+
<Hint>
431+
Actual run times will get a fixed offset based on the window, displayed after
432+
creation.
433+
</Hint>
434+
)}
380435
<Table>
381436
<TableHeader>
382437
<TableRow>
@@ -525,17 +580,29 @@ function buttonText(mode: "edit" | "new", isLoading: boolean) {
525580
}
526581
}
527582

528-
function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) {
583+
function ValidationMessage({
584+
id,
585+
isValid,
586+
validLabel,
587+
invalidLabel,
588+
message,
589+
}: {
590+
id?: string;
591+
isValid: boolean;
592+
validLabel: string;
593+
invalidLabel: string;
594+
message: string;
595+
}) {
529596
return (
530-
<Paragraph variant="small">
597+
<Paragraph id={id} variant="small">
531598
<span className="mr-1">
532599
{isValid ? (
533600
<CheckIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-success" />
534601
) : (
535602
<XMarkIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-error" />
536603
)}
537604
<span className={isValid ? "text-success" : "text-error"}>
538-
{isValid ? "Valid pattern:" : "Invalid pattern:"}
605+
{isValid ? validLabel : invalidLabel}
539606
</span>
540607
</span>
541608
<span>{message}</span>

packages/core/src/v3/schemas/scheduleWindow.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ describe("ScheduleWindow", () => {
3333
expect(ScheduleWindow.safeParse(window).success).toBe(false);
3434
});
3535

36+
it("reports percentages above 100% precisely", () => {
37+
expect(() => parseScheduleWindow("110%")).toThrow(
38+
"Schedule window percentage cannot exceed 100%"
39+
);
40+
});
41+
3642
it("normalizes valid windows", () => {
3743
expect(parseScheduleWindow("30m")).toEqual({
3844
type: "duration",

packages/core/src/v3/schemas/scheduleWindow.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,19 @@ export function parseScheduleWindow(value: string): NormalizedScheduleWindow {
106106
return { type: "duration", durationSeconds };
107107
}
108108

109-
const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value);
109+
const percentageMatch = /^(0|[1-9]\d*)%$/.exec(value);
110110
if (percentageMatch) {
111-
return { type: "percentage", percentage: Number(percentageMatch[1]) };
111+
const percentage = Number(percentageMatch[1]);
112+
113+
if (!Number.isSafeInteger(percentage) || percentage > 100) {
114+
throw new RangeError("Schedule window percentage cannot exceed 100%");
115+
}
116+
117+
return { type: "percentage", percentage };
112118
}
113119

114120
throw new TypeError(
115-
'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"'
121+
'Schedule window must be a whole duration such as "30m" or "2h", or a percentage such as "30%"'
116122
);
117123
}
118124

0 commit comments

Comments
 (0)