Skip to content

Commit 991edd1

Browse files
committed
upd
1 parent b8193bd commit 991edd1

2 files changed

Lines changed: 205 additions & 44 deletions

File tree

packages/ui/src/features/loops/components/LoopForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ export function LoopForm({ loop }: LoopFormProps) {
299299
disabled={!stepComplete[step] || isSubmitting}
300300
onClick={() => setStep((s) => s + 1)}
301301
>
302-
Next: {STEPS[step + 1]}
302+
Next
303303
<ArrowRight size={13} />
304304
</Button>
305305
)}

packages/ui/src/features/loops/components/LoopTriggerEditor.tsx

Lines changed: 204 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Text,
1919
TextField,
2020
} from "@radix-ui/themes";
21+
import { useState } from "react";
2122
import {
2223
emptyLoopApiTriggerConfig,
2324
emptyLoopGithubTriggerConfig,
@@ -222,12 +223,92 @@ function TriggerCard({
222223
);
223224
}
224225

225-
type ScheduleMode = "recurring" | "one_time";
226+
type ScheduleFrequency =
227+
| "hourly"
228+
| "daily"
229+
| "weekdays"
230+
| "weekly"
231+
| "once"
232+
| "custom";
226233

227-
function scheduleModeOf(
228-
config: LoopSchemas.LoopScheduleTriggerConfig,
229-
): ScheduleMode {
230-
return config.run_at ? "one_time" : "recurring";
234+
const FREQUENCY_OPTIONS: { value: ScheduleFrequency; label: string }[] = [
235+
{ value: "hourly", label: "Hourly" },
236+
{ value: "daily", label: "Daily" },
237+
{ value: "weekdays", label: "Weekdays" },
238+
{ value: "weekly", label: "Weekly" },
239+
{ value: "once", label: "Once" },
240+
{ value: "custom", label: "Custom (cron)" },
241+
];
242+
243+
const WEEKDAY_OPTIONS = [
244+
{ value: "0", label: "Sunday" },
245+
{ value: "1", label: "Monday" },
246+
{ value: "2", label: "Tuesday" },
247+
{ value: "3", label: "Wednesday" },
248+
{ value: "4", label: "Thursday" },
249+
{ value: "5", label: "Friday" },
250+
{ value: "6", label: "Saturday" },
251+
];
252+
253+
const DEFAULT_TIME = "09:00";
254+
255+
function localTimezone(): string {
256+
try {
257+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
258+
} catch {
259+
return "UTC";
260+
}
261+
}
262+
263+
interface ParsedRecurringSchedule {
264+
frequency: "hourly" | "daily" | "weekdays" | "weekly";
265+
time: string;
266+
weekday: string;
267+
}
268+
269+
/** Reads the simple shapes the frequency picker writes; anything else is custom. */
270+
function parseCronSchedule(
271+
cron: string | null | undefined,
272+
): ParsedRecurringSchedule | null {
273+
if (!cron) return null;
274+
const parts = cron.trim().split(/\s+/);
275+
if (parts.length !== 5) return null;
276+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
277+
if (dayOfMonth !== "*" || month !== "*") return null;
278+
if (!/^\d{1,2}$/.test(minute)) return null;
279+
if (hour === "*") {
280+
return dayOfWeek === "*"
281+
? { frequency: "hourly", time: DEFAULT_TIME, weekday: "1" }
282+
: null;
283+
}
284+
if (!/^\d{1,2}$/.test(hour)) return null;
285+
const time = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
286+
if (dayOfWeek === "*") return { frequency: "daily", time, weekday: "1" };
287+
if (dayOfWeek === "1-5") return { frequency: "weekdays", time, weekday: "1" };
288+
if (/^[0-6]$/.test(dayOfWeek)) {
289+
return { frequency: "weekly", time, weekday: dayOfWeek };
290+
}
291+
return null;
292+
}
293+
294+
function compileCronSchedule(
295+
frequency: "hourly" | "daily" | "weekdays" | "weekly",
296+
time: string,
297+
weekday: string,
298+
): string {
299+
const [hourPart, minutePart] = time.split(":");
300+
const hour = Number(hourPart) || 0;
301+
const minute = Number(minutePart) || 0;
302+
switch (frequency) {
303+
case "hourly":
304+
return "0 * * * *";
305+
case "daily":
306+
return `${minute} ${hour} * * *`;
307+
case "weekdays":
308+
return `${minute} ${hour} * * 1-5`;
309+
case "weekly":
310+
return `${minute} ${hour} * * ${weekday}`;
311+
}
231312
}
232313

233314
function ScheduleTriggerFields({
@@ -239,30 +320,125 @@ function ScheduleTriggerFields({
239320
disabled?: boolean;
240321
onChange: (config: LoopSchemas.LoopScheduleTriggerConfig) => void;
241322
}) {
242-
const mode = scheduleModeOf(config);
323+
const parsed = parseCronSchedule(config.cron_expression);
324+
// Custom is sticky UI state: "0 9 * * *" typed by hand still parses as
325+
// daily, so deriving the mode from the config alone would bounce the user
326+
// out of the cron editor while they type.
327+
const [customMode, setCustomMode] = useState(
328+
() => !config.run_at && !!config.cron_expression && !parsed,
329+
);
330+
const frequency: ScheduleFrequency = config.run_at
331+
? "once"
332+
: customMode
333+
? "custom"
334+
: (parsed?.frequency ?? "daily");
335+
const time = parsed?.time ?? DEFAULT_TIME;
336+
const weekday = parsed?.weekday ?? "1";
337+
const timezone = config.timezone || localTimezone();
338+
339+
const setRecurring = (
340+
nextFrequency: "hourly" | "daily" | "weekdays" | "weekly",
341+
nextTime: string,
342+
nextWeekday: string,
343+
) => {
344+
onChange({
345+
cron_expression: compileCronSchedule(
346+
nextFrequency,
347+
nextTime,
348+
nextWeekday,
349+
),
350+
timezone,
351+
});
352+
};
353+
354+
const handleFrequencyChange = (value: string) => {
355+
const next = value as ScheduleFrequency;
356+
if (next === "once") {
357+
setCustomMode(false);
358+
onChange({ run_at: new Date().toISOString() });
359+
return;
360+
}
361+
if (next === "custom") {
362+
setCustomMode(true);
363+
onChange({
364+
cron_expression:
365+
config.cron_expression ??
366+
emptyLoopScheduleTriggerConfig().cron_expression,
367+
timezone,
368+
});
369+
return;
370+
}
371+
setCustomMode(false);
372+
setRecurring(next, time, weekday);
373+
};
374+
375+
const timeInput = (
376+
<input
377+
type="time"
378+
disabled={disabled}
379+
value={time}
380+
className="h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
381+
onChange={(e) => {
382+
if (!e.target.value || frequency === "custom" || frequency === "once")
383+
return;
384+
setRecurring(
385+
frequency as "daily" | "weekdays" | "weekly",
386+
e.target.value,
387+
weekday,
388+
);
389+
}}
390+
/>
391+
);
243392

244393
return (
245394
<Flex direction="column" gap="2">
246-
<Box className="max-w-[220px]">
247-
<SettingsOptionSelect
248-
value={mode}
249-
options={[
250-
{ value: "recurring", label: "Recurring (cron)" },
251-
{ value: "one_time", label: "One-time" },
252-
]}
253-
disabled={disabled}
254-
ariaLabel="Schedule mode"
255-
onValueChange={(value) =>
256-
onChange(
257-
value === "one_time"
258-
? { run_at: new Date().toISOString() }
259-
: emptyLoopScheduleTriggerConfig(),
260-
)
261-
}
262-
/>
263-
</Box>
395+
<Flex align="center" gap="2" wrap="wrap">
396+
<Box className="min-w-[150px]">
397+
<SettingsOptionSelect
398+
value={frequency}
399+
options={FREQUENCY_OPTIONS}
400+
disabled={disabled}
401+
ariaLabel="Frequency"
402+
onValueChange={handleFrequencyChange}
403+
/>
404+
</Box>
405+
406+
{frequency === "daily" ||
407+
frequency === "weekdays" ||
408+
frequency === "weekly"
409+
? timeInput
410+
: null}
264411

265-
{mode === "recurring" ? (
412+
{frequency === "weekly" ? (
413+
<Box className="min-w-[140px]">
414+
<SettingsOptionSelect
415+
value={weekday}
416+
options={WEEKDAY_OPTIONS}
417+
disabled={disabled}
418+
ariaLabel="Day of week"
419+
onValueChange={(value) => setRecurring("weekly", time, value)}
420+
/>
421+
</Box>
422+
) : null}
423+
424+
{frequency === "once" ? (
425+
<input
426+
type="datetime-local"
427+
disabled={disabled}
428+
className="h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
429+
value={config.run_at ? toDatetimeLocal(config.run_at) : ""}
430+
onChange={(e) =>
431+
onChange({
432+
run_at: e.target.value
433+
? new Date(e.target.value).toISOString()
434+
: undefined,
435+
})
436+
}
437+
/>
438+
) : null}
439+
</Flex>
440+
441+
{frequency === "custom" ? (
266442
<Flex gap="2" wrap="wrap">
267443
<Flex direction="column" gap="1" className="min-w-[160px] flex-1">
268444
<Text className="text-[12px] text-gray-10">Cron expression</Text>
@@ -289,24 +465,9 @@ function ScheduleTriggerFields({
289465
/>
290466
</Flex>
291467
</Flex>
292-
) : (
293-
<Flex direction="column" gap="1" className="max-w-[240px]">
294-
<Text className="text-[12px] text-gray-10">Run at</Text>
295-
<input
296-
type="datetime-local"
297-
disabled={disabled}
298-
className="h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
299-
value={config.run_at ? toDatetimeLocal(config.run_at) : ""}
300-
onChange={(e) =>
301-
onChange({
302-
run_at: e.target.value
303-
? new Date(e.target.value).toISOString()
304-
: undefined,
305-
})
306-
}
307-
/>
308-
</Flex>
309-
)}
468+
) : frequency !== "once" ? (
469+
<Text className="text-[11px] text-gray-9">Times in {timezone}</Text>
470+
) : null}
310471
</Flex>
311472
);
312473
}

0 commit comments

Comments
 (0)