Files
paperclip/ui/src/components/ScheduleEditor.tsx
T
Aron Prins c0c58d6b01 fix(ui): prevent lossy cron rewrites + redesign routine triggers tab (#3569)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Humans configure when those agents run via **routines**, which are
driven by cron-backed triggers
> - The routine detail page exposed triggers through an always-visible
inline add form and per-row inline editor, with a ScheduleEditor that
only understood a narrow set of cron shapes
> - That combination was actively lossy: pasting `0 9,13,17 * * *`
silently collapsed to `0 10 * * *` on save, and common shapes
(every-N-minutes within a window, multiple times per day, monthly on
several dates) had no first-class UI
> - This pull request rebuilds the triggers tab around a list of cards +
add/edit modal, teaches ScheduleEditor the cron shapes users actually
want, and prevents cron round-trips from dropping data
> - It also *optionally* tucks the Triggers/Runs/Activity tabs into the
shared right-hand PropertiesPanel (same pattern as Issues and Goals) so
they stay in view alongside the routine instead of being hidden below
the main content
> - The benefit is that routine scheduling becomes non-destructive and
legible — operators can see, describe, and edit real-world schedules
without dropping into raw cron and without fear that saving will
silently rewrite their trigger

## What Changed

**Core fixes + redesign (required):**
- **ScheduleEditor correctness** — `parseCronToPreset` now detects comma
lists, ranges, steps, and unknown tokens across every cron field and
routes anything it can't round-trip losslessly to the `custom` preset
(except `dow === "1-5"` → `weekdays`). Fixes the `0 9,13,17 * * *` → `0
10 * * *` regression.
- **ScheduleEditor presets** — adds first-class support for
every-N-minutes (with optional hour window + weekdays-only),
every-N-hours, hourly at minute offset, daily with multiple times/day,
selected-days-of-week with multiple times, and monthly on multiple
dates. `describeSchedule` unfolds multi-value hour/day lists into
readable sentences.
- **ScheduleEditor polish** — swaps raw `<input type=\"checkbox\">` for
the shadcn `Checkbox` primitive so hour-window and weekdays-only toggles
match the rest of the app.
- **Triggers tab redesign** — replaces the inline add form + inline
editor with a header + \"Add trigger\" button, compact `TriggerListCard`
entries, and a `TriggerDialog` add/edit modal. Enable/disable is now a
single-click switch on each card; delete goes through a `ConfirmDialog`.
- **Webhook trigger gating** — webhook kind is visible but disabled with
\"— COMING SOON\" in the add dialog, matching the old inline form's
production behaviour. Editing existing webhook triggers still works.
- **Tests** — adds `ScheduleEditor.test.ts` covering the regression cron
strings (`0 9,13,17 * * *`, `0 */4 * * *`, `0 10,16 * * *`) plus
existing preset patterns as regression guards in the other direction.

**Optional layout change (commit `145a86b5` — can be dropped without
affecting the rest):**
- Moves Triggers/Runs/Activity into the shared right-hand
`PropertiesPanel` (persisted open/close, header toggle button),
mirroring `IssueDetail` and `GoalDetail`. The reasoning: these tabs are
the primary way a human *operates* a routine, and keeping them docked on
the right means they're always in view next to the routine content
rather than hidden below the fold. Mobile parity is preserved by
rendering the same tabs inline below `md`. Trigger cards and
run/activity rows were restructured into vertical stacks so they fit the
320px panel without overflow, and the last-result badge became a
wrapping inline chip so long error strings no longer fill the card
width.
- **If reviewers prefer to keep the tabs inline below the routine, this
commit can be reverted cleanly without touching any of the fixes
above.**

## Screenshots:

Old:
<img width="721" height="707" alt="triggers-old"
src="https://github.com/user-attachments/assets/260bb682-32cb-4dff-b038-d55e45824b04"
/>

New: 
<img width="1410" height="1325" alt="Screenshot 2026-04-13 at 12 25 00"
src="https://github.com/user-attachments/assets/d70dd35b-e72f-4fc6-bb21-be9b0d92b3b1"
/>

New Add Trigger modal:
<img width="1408" height="1321" alt="Screenshot 2026-04-13 at 12 25 07"
src="https://github.com/user-attachments/assets/0f23a83d-ba2c-47ed-9efa-829e777dcdf5"
/>

Commit 145a86b5 Properties panel:
<img width="1409" height="830"
alt="commit-145a86b51265e326160cb8c48e0874cb36d86f37"
src="https://github.com/user-attachments/assets/f1d42f07-7cd3-4614-8e93-5b585affd4bf"
/>

## Verification

- `cd ui && npm test -- ScheduleEditor` — new cron parser/describer
cases pass.
- Full UI test suite + typecheck green locally.
- Manual:
1. Open a routine → Triggers tab → verify cards render with enable
switch, edit, and delete (confirm dialog).
2. Create a schedule trigger with each preset (every-N-min with window,
every-N-hours, hourly@offset, daily multi-time, weekly multi-time,
monthly multi-date) → save → reopen → preset + values round-trip intact.
3. Paste `0 9,13,17 * * *` into an existing trigger → editor routes to
Custom with the raw cron preserved → save → value unchanged.
4. Try to add a webhook trigger → kind option shows \"— COMING SOON\"
and is disabled; edit an existing webhook trigger still works.
5. Toggle the properties panel via header button → state persists across
reload. Resize below `md` → tabs render inline.
- **Before/after screenshots:** attached in PR description (inline
triggers tab → list+modal; raw-cron save hazard → custom preset
preservation; bottom-of-page tabs → right-hand PropertiesPanel).

## Risks

- **Medium-low.** UI-only change; no API, schema, or migration impact.
- `parseCronToPreset` / `describeSchedule` signatures are preserved, but
their *behaviour* shifts: more cron strings now resolve to `custom` than
before. Any external caller relying on the old (lossy) classification
would see different preset tags — none known in-repo.
- PropertiesPanel reuse (optional commit) depends on the existing
localStorage key behaviour; if two routes ever write conflicting
open/close state under the same key, one could clobber the other.
Mirrors the established `IssueDetail`/`GoalDetail` pattern, so risk is
bounded. Reverting `145a86b5` removes this risk entirely while keeping
the fixes.
- Webhook kind is disabled in the add dialog only; existing webhook
triggers remain editable, so no data is stranded.

## Model Used

- **Authoring / PR drafting:** Anthropic Claude — `claude-opus-4-6` (1M
context window), via Claude Code CLI. Used for diff review and PR
description drafting. Code authored by @aronprins.
- **Post-hoc audit:** OpenAI Codex — `gpt-5.4` (high reasoning). Audited
the completed work after implementation; found no issues.

## Checklist

- [x] Thinking path traces from project context to this change
- [x] Model used specified with version + capability details
- [x] Tests run locally and pass
- [x] Added/updated tests (`ScheduleEditor.test.ts`)
- [x] Before/after screenshots attached
- [ ] Documentation updated — none required (internal UI only)
- [x] Risks documented
- [x] Will address all Greptile + reviewer comments before merge
2026-05-11 00:53:10 -07:00

1041 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { X } from "lucide-react";
// ---------------------------------------------------------------------------
// Public (stable) types & helpers
// ---------------------------------------------------------------------------
/**
* Limited preset set kept for backwards compatibility. `parseCronToPreset` and
* the `describeSchedule` fallback rely on this set. Any cron that can't be
* expressed with a single hour / minute / day-of-week / day-of-month routes
* to "custom" from this parser so old callers never see a lossy preset.
*/
export type SchedulePreset =
| "every_minute"
| "every_hour"
| "every_day"
| "weekdays"
| "weekly"
| "monthly"
| "custom";
const DAY_NAMES_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DAY_NAMES_LONG = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function ordinalSuffix(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
function ordinal(n: number): string {
return `${n}${ordinalSuffix(n)}`;
}
function isSimpleCronField(field: string): boolean {
return field === "*" || /^\d+$/.test(field);
}
function parseTimeParts(time: string): { hour: number; minute: number } | null {
const match = time.match(/^(\d{2}):(\d{2})$/);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return { hour, minute };
}
function hasSingleMinuteAcrossTimes(times: string[]): boolean {
const parsed = times.map(parseTimeParts);
if (parsed.some((value) => value == null)) return false;
const minutes = new Set(parsed.map((value) => value!.minute));
return minutes.size <= 1;
}
// ---------------------------------------------------------------------------
// Back-compat parser (kept so tests and any external callers continue to work)
// ---------------------------------------------------------------------------
/**
* Parse a cron into one of the *limited* presets (original behaviour).
* Complex expressions (comma lists, ranges, steps, named tokens) all map to
* "custom" so the caller can safely round-trip the raw string without losing
* multi-value information. Don't change these semantics without updating the
* ScheduleEditor tests — they intentionally guard against silent collapse of
* multi-value crons like `0 9,13,17 * * *`.
*/
export function parseCronToPreset(cron: string): {
preset: SchedulePreset;
hour: string;
minute: string;
dayOfWeek: string;
dayOfMonth: string;
} {
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
if (!cron || !cron.trim()) {
return { preset: "every_day", ...defaults };
}
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { preset: "custom", ...defaults };
}
const [min, hr, dom, month, dow] = parts;
const dowIsWeekdayRange = dow === "1-5";
const allFieldsSimple =
isSimpleCronField(min) &&
isSimpleCronField(hr) &&
isSimpleCronField(dom) &&
isSimpleCronField(month) &&
(isSimpleCronField(dow) || dowIsWeekdayRange);
if (!allFieldsSimple) {
return { preset: "custom", ...defaults };
}
if (month !== "*") {
return { preset: "custom", ...defaults };
}
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_minute", ...defaults };
}
if (hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
}
if (dom === "*" && dow === "*" && hr !== "*") {
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
if (dom === "*" && dow === "1-5" && hr !== "*") {
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
return {
preset: "weekly",
...defaults,
hour: hr,
minute: min === "*" ? "0" : min,
dayOfWeek: dow,
};
}
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
return {
preset: "monthly",
...defaults,
hour: hr,
minute: min === "*" ? "0" : min,
dayOfMonth: dom,
};
}
return { preset: "custom", ...defaults };
}
// ---------------------------------------------------------------------------
// Richer internal parser that can handle multi-value fields
// ---------------------------------------------------------------------------
type EditorPreset =
| "every_minute"
| "every_n_minutes"
| "hourly"
| "every_n_hours"
| "daily"
| "weekdays"
| "monthly"
| "custom";
interface EditorState {
preset: EditorPreset;
n: number; // every_n_minutes, every_n_hours
windowEnabled: boolean;
windowStart: number;
windowEnd: number;
weekdaysOnly: boolean;
minutePast: number; // hourly, every_n_hours
times: string[]; // "HH:mm" strings, for daily / weekdays / monthly
days: number[]; // 0-6, for weekdays preset
domDays: number[]; // 1-31, for monthly
custom: string;
}
const DEFAULT_STATE: EditorState = {
preset: "daily",
n: 15,
windowEnabled: false,
windowStart: 9,
windowEnd: 17,
weekdaysOnly: false,
minutePast: 0,
times: ["09:00"],
days: [1, 2, 3, 4, 5],
domDays: [1],
custom: "0 10 * * *",
};
function parseCronField(field: string, min: number, max: number): number[] {
if (field === "*") {
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
}
const parts = field.split(",");
const out = new Set<number>();
for (const p of parts) {
if (!p) {
throw new Error("Invalid cron field");
}
const stepMatch = p.match(/^(.+)\/(\d+)$/);
let base = p;
let step = 1;
if (stepMatch) {
base = stepMatch[1];
step = parseInt(stepMatch[2], 10);
if (!Number.isInteger(step) || step <= 0) {
throw new Error("Invalid cron step");
}
}
if (base === "*") {
for (let i = min; i <= max; i += step) out.add(i);
} else if (base.includes("-")) {
if (!/^\d+-\d+$/.test(base)) {
throw new Error("Invalid cron range");
}
const [a, b] = base.split("-").map(Number);
if (a > b) {
throw new Error("Invalid cron range");
}
for (let i = a; i <= b; i += step) out.add(i);
} else {
if (!/^\d+$/.test(base)) {
throw new Error("Invalid cron value");
}
const n = parseInt(base, 10);
out.add(n);
}
}
for (const value of out) {
if (value < min || value > max) {
throw new Error("Cron value out of range");
}
}
const sorted = [...out].sort((a, b) => a - b);
if (sorted.length === 0) {
throw new Error("Invalid cron field");
}
return sorted;
}
function timesFromFields(minuteField: string, hourField: string): string[] | null {
const minutes = parseCronField(minuteField, 0, 59);
const hours = parseCronField(hourField, 0, 23);
if (minutes.length > 1) {
return null;
}
const out: string[] = [];
for (const h of hours) for (const mi of minutes) out.push(`${pad(h)}:${pad(mi)}`);
return out.length > 0 && out.length <= 24 ? out : null;
}
function parseCronToEditorState(cron: string): EditorState {
if (!cron || !cron.trim()) return { ...DEFAULT_STATE };
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
const [m, h, dom, mon, dow] = fields;
// validate each field is parseable
try {
parseCronField(m, 0, 59);
parseCronField(h, 0, 23);
parseCronField(dom, 1, 31);
parseCronField(mon, 1, 12);
parseCronField(dow.replace(/7/g, "0"), 0, 6);
} catch {
return { ...DEFAULT_STATE, preset: "custom", custom: cron };
}
// non-wildcard month → custom
if (mon !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron };
// every minute
if (cron.trim() === "* * * * *") return { ...DEFAULT_STATE, preset: "every_minute" };
// every N minutes (optionally windowed / weekdays-only)
const minuteStep = m.match(/^\*\/(\d+)$/);
const hourRange = h.match(/^(\d+)-(\d+)$/);
if (minuteStep && dom === "*") {
const n = parseInt(minuteStep[1], 10);
const state = { ...DEFAULT_STATE, preset: "every_n_minutes" as EditorPreset, n };
if (h !== "*") {
if (hourRange) {
state.windowEnabled = true;
state.windowStart = +hourRange[1];
state.windowEnd = +hourRange[2];
} else {
// unsupported hour field for this preset → custom
return { ...DEFAULT_STATE, preset: "custom", custom: cron };
}
}
if (dow === "1-5") state.weekdaysOnly = true;
else if (dow !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron };
return state;
}
// hourly: single minute, hour=*
if (/^\d+$/.test(m) && h === "*" && dom === "*" && dow === "*") {
return { ...DEFAULT_STATE, preset: "hourly", minutePast: parseInt(m, 10) };
}
if (/^\d+$/.test(m) && h === "*" && dom === "*" && dow !== "*") {
return { ...DEFAULT_STATE, preset: "custom", custom: cron };
}
// every N hours
const hourStep = h.match(/^\*\/(\d+)$/);
if (/^\d+$/.test(m) && hourStep && dom === "*") {
const state = {
...DEFAULT_STATE,
preset: "every_n_hours" as EditorPreset,
n: parseInt(hourStep[1], 10),
minutePast: parseInt(m, 10),
};
if (dow === "1-5") state.weekdaysOnly = true;
else if (dow !== "*") return { ...DEFAULT_STATE, preset: "custom", custom: cron };
return state;
}
// monthly: specific dom (may be multi), dow = *
if (dom !== "*" && dow === "*") {
const domDays = parseCronField(dom, 1, 31);
const times = timesFromFields(m, h);
if (!times) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
if (domDays.length === 0) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
return {
...DEFAULT_STATE,
preset: "monthly",
domDays,
times,
};
}
// weekdays (any subset of days)
if (dom === "*" && dow !== "*") {
const days = parseCronField(dow.replace(/7/g, "0"), 0, 6);
const times = timesFromFields(m, h);
if (!times) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
if (days.length === 0) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
return {
...DEFAULT_STATE,
preset: "weekdays",
days,
times,
};
}
// daily (any time(s))
if (dom === "*" && dow === "*") {
const times = timesFromFields(m, h);
if (!times) return { ...DEFAULT_STATE, preset: "custom", custom: cron };
return {
...DEFAULT_STATE,
preset: "daily",
times,
};
}
return { ...DEFAULT_STATE, preset: "custom", custom: cron };
}
function buildCronFromState(s: EditorState): string {
const fmt = (arr: number[] | string): string => {
if (typeof arr === "string") return arr;
if (arr.length === 0) return "*";
return arr.join(",");
};
const isWeekdayRangeSelection = (days: number[]): boolean =>
days.length === 5 && days.every((day, index) => day === index + 1);
switch (s.preset) {
case "every_minute":
return "* * * * *";
case "every_n_minutes": {
const hourField = s.windowEnabled ? `${s.windowStart}-${s.windowEnd}` : "*";
const dowField = s.weekdaysOnly ? "1-5" : "*";
return `*/${s.n} ${hourField} * * ${dowField}`;
}
case "hourly":
return `${s.minutePast} * * * *`;
case "every_n_hours": {
const dowField = s.weekdaysOnly ? "1-5" : "*";
return `${s.minutePast} */${s.n} * * ${dowField}`;
}
case "daily": {
const parsedTimes = s.times.map(parseTimeParts).filter((value): value is NonNullable<typeof value> => value != null);
const minute = parsedTimes[0]?.minute ?? 0;
const hours = [...new Set(parsedTimes.map((time) => time.hour))].sort((a, b) => a - b);
return `${minute} ${fmt(hours)} * * *`;
}
case "weekdays": {
const parsedTimes = s.times.map(parseTimeParts).filter((value): value is NonNullable<typeof value> => value != null);
const minute = parsedTimes[0]?.minute ?? 0;
const hours = [...new Set(parsedTimes.map((time) => time.hour))].sort((a, b) => a - b);
const days = s.days.length === 0
? "*"
: isWeekdayRangeSelection(s.days)
? "1-5"
: s.days.slice().sort((a, b) => a - b).join(",");
return `${minute} ${fmt(hours)} * * ${days}`;
}
case "monthly": {
const parsedTimes = s.times.map(parseTimeParts).filter((value): value is NonNullable<typeof value> => value != null);
const minute = parsedTimes[0]?.minute ?? 0;
const hours = [...new Set(parsedTimes.map((time) => time.hour))].sort((a, b) => a - b);
const doms = s.domDays.length === 0 ? [1] : s.domDays.slice().sort((a, b) => a - b);
return `${minute} ${fmt(hours)} ${fmt(doms)} * *`;
}
case "custom":
return s.custom;
}
}
// ---------------------------------------------------------------------------
// Rich describer that handles multi-value fields
// ---------------------------------------------------------------------------
/**
* Produce a human-readable description of a cron expression. Handles
* multi-value time and day fields (e.g. `0 9,13,17 * * 1-5` becomes
* "Every weekday at 09:00, 13:00 and 17:00"). Falls back to the raw cron
* expression when it can't confidently describe the schedule.
*/
export function describeSchedule(cron: string): string {
if (!cron || !cron.trim()) return "Every day at 10:00";
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return cron;
const [m, h, dom, mon, dow] = fields;
let minutes: number[], hours: number[], daysOfWeek: number[], daysOfMonth: number[];
try {
minutes = parseCronField(m, 0, 59);
hours = parseCronField(h, 0, 23);
daysOfMonth = parseCronField(dom, 1, 31);
parseCronField(mon, 1, 12);
daysOfWeek = parseCronField(dow.replace(/7/g, "0"), 0, 6);
} catch {
return cron;
}
if (mon !== "*") return cron;
if (minutes.length === 60 && hours.length === 24) return "Every minute";
const minStep = m.match(/^\*\/(\d+)$/);
if (minStep && h === "*" && dom === "*" && dow === "*") {
return `Every ${minStep[1]} minutes`;
}
if (minStep && h === "*" && dom === "*" && dow === "1-5") {
return `Every ${minStep[1]} minutes, weekdays`;
}
const hourRange = h.match(/^(\d+)-(\d+)$/);
if (minStep && hourRange && dom === "*") {
const dayPart = dow === "1-5" ? "weekdays" : dow === "*" ? "every day" : "selected days";
return `Every ${minStep[1]} minutes between ${pad(+hourRange[1])}:00 and ${pad(+hourRange[2])}:00, ${dayPart}`;
}
if (minutes.length === 1 && h === "*" && dom === "*" && dow === "*") {
return `Every hour at :${pad(minutes[0])}`;
}
const hourStep = h.match(/^\*\/(\d+)$/);
if (minutes.length === 1 && hourStep && dom === "*") {
const dayPart = dow === "1-5" ? ", weekdays" : "";
return `Every ${hourStep[1]} hours at :${pad(minutes[0])}${dayPart}`;
}
// day phrase
let dayPart = "";
if (dom === "*" && dow === "*") dayPart = "every day";
else if (dom === "*" && dow === "1-5") dayPart = "every weekday";
else if (dom === "*" && (dow === "0,6" || dow === "6,0")) dayPart = "every weekend";
else if (dom === "*") {
if (daysOfWeek.length === 1) dayPart = `every ${DAY_NAMES_LONG[daysOfWeek[0]]}`;
else dayPart = `every ${daysOfWeek.map((d) => DAY_NAMES_SHORT[d]).join(", ")}`;
} else if (dow === "*") {
if (daysOfMonth.length === 1) dayPart = `on the ${ordinal(daysOfMonth[0])} of the month`;
else dayPart = `on the ${daysOfMonth.map(ordinal).join(", ")} of the month`;
} else {
return cron;
}
// time phrase
const timeStrs: string[] = [];
for (const hh of hours) for (const mi of minutes) timeStrs.push(`${pad(hh)}:${pad(mi)}`);
let timePart = "";
if (timeStrs.length === 1) timePart = `at ${timeStrs[0]}`;
else if (timeStrs.length <= 4) {
timePart = `at ${timeStrs.slice(0, -1).join(", ")} and ${timeStrs[timeStrs.length - 1]}`;
} else timePart = `${timeStrs.length} times per day`;
const sentence = `${dayPart} ${timePart}`.replace(/\s+/g, " ").trim();
return sentence.charAt(0).toUpperCase() + sentence.slice(1);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const PRESET_OPTIONS: { value: EditorPreset; label: string }[] = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_n_minutes", label: "Every N minutes" },
{ value: "hourly", label: "Hourly" },
{ value: "every_n_hours", label: "Every N hours" },
{ value: "daily", label: "Daily — at one or more times" },
{ value: "weekdays", label: "On selected days of the week" },
{ value: "monthly", label: "Monthly — on selected dates" },
{ value: "custom", label: "Custom (cron expression)" },
];
function TimeList({
times,
onChange,
}: {
times: string[];
onChange: (next: string[]) => void;
}) {
return (
<div className="space-y-2">
{times.map((t, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="time"
value={t}
className="font-mono w-32"
onChange={(e) => {
const next = times.slice();
const value = e.target.value || "00:00";
next[i] = value;
if (next.length > 1) {
const parsed = parseTimeParts(value);
if (parsed) {
for (let idx = 0; idx < next.length; idx += 1) {
const current = parseTimeParts(next[idx] ?? "");
const hour = idx === i ? parsed.hour : (current?.hour ?? 0);
next[idx] = `${pad(hour)}:${pad(parsed.minute)}`;
}
}
}
onChange(next);
}}
/>
<Button
type="button"
variant="ghost"
size="sm"
disabled={times.length <= 1}
onClick={() => {
const next = times.filter((_, idx) => idx !== i);
onChange(next);
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange([...times, "12:00"])}
>
+ Add time
</Button>
</div>
);
}
function DayOfWeekPicker({
days,
onChange,
}: {
days: number[];
onChange: (next: number[]) => void;
}) {
const letters = ["S", "M", "T", "W", "T", "F", "S"];
return (
<div className="flex gap-1.5 flex-wrap">
{letters.map((l, i) => {
const active = days.includes(i);
return (
<Button
key={i}
type="button"
variant={active ? "default" : "outline"}
size="sm"
className="h-9 w-9 p-0"
title={DAY_NAMES_LONG[i]}
onClick={() => {
const next = active ? days.filter((d) => d !== i) : [...days, i].sort((a, b) => a - b);
onChange(next.length === 0 ? [i] : next);
}}
>
{l}
</Button>
);
})}
</div>
);
}
function DayOfMonthPicker({
domDays,
onChange,
}: {
domDays: number[];
onChange: (next: number[]) => void;
}) {
return (
<div className="grid grid-cols-7 sm:grid-cols-10 gap-1.5">
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => {
const active = domDays.includes(d);
return (
<Button
key={d}
type="button"
variant={active ? "default" : "outline"}
size="sm"
className="h-8 px-0 text-xs"
onClick={() => {
const next = active ? domDays.filter((x) => x !== d) : [...domDays, d].sort((a, b) => a - b);
onChange(next.length === 0 ? [d] : next);
}}
>
{d}
</Button>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// ScheduleEditor component (rich)
// ---------------------------------------------------------------------------
export function ScheduleEditor({
value,
onChange,
}: {
value: string;
onChange: (cron: string) => void;
}) {
const [state, setState] = useState<EditorState>(() => parseCronToEditorState(value));
// Sync when external value changes and isn't the same cron we just emitted.
useEffect(() => {
const currentCron = buildCronFromState(state);
if (currentCron !== value) {
setState(parseCronToEditorState(value));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const emitState = useCallback(
(next: EditorState) => {
setState(next);
onChange(buildCronFromState(next));
},
[onChange],
);
const update = useCallback(
<K extends keyof EditorState>(patch: Pick<EditorState, K> | Partial<EditorState>) => {
emitState({ ...state, ...patch });
},
[emitState, state],
);
const { preset } = state;
return (
<div className="space-y-4">
{/* Preset */}
<div className="space-y-1.5">
<Label className="text-xs">Schedule</Label>
<Select value={preset} onValueChange={(p) => emitState(changePreset(state, p as EditorPreset))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESET_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{preset === "every_minute" && (
<p className="text-xs text-muted-foreground">
No options runs every minute, around the clock.
</p>
)}
{preset === "every_n_minutes" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Run every</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={59}
className="w-24 font-mono"
value={state.n}
onChange={(e) => update({ n: clamp(+e.target.value || 1, 1, 59) })}
/>
<span className="text-sm text-muted-foreground">minutes</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{[1, 5, 10, 15, 20, 30].map((v) => (
<Badge
key={v}
variant="outline"
className="cursor-pointer hover:bg-accent"
onClick={() => update({ n: v })}
>
{v}
</Badge>
))}
</div>
</div>
<WindowAndWeekdaysToggles state={state} update={update} />
</div>
)}
{preset === "hourly" && (
<div className="space-y-1.5">
<Label className="text-xs">Minute past the hour</Label>
<Input
type="number"
min={0}
max={59}
className="w-24 font-mono"
value={state.minutePast}
onChange={(e) => update({ minutePast: clamp(+e.target.value || 0, 0, 59) })}
/>
<p className="text-xs text-muted-foreground">
Runs once an hour at <span className="font-mono">:{pad(state.minutePast)}</span>
</p>
</div>
)}
{preset === "every_n_hours" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Run every</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={23}
className="w-24 font-mono"
value={state.n}
onChange={(e) => update({ n: clamp(+e.target.value || 1, 1, 23) })}
/>
<span className="text-sm text-muted-foreground">hours</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{[1, 2, 3, 4, 6, 8, 12].map((v) => (
<Badge
key={v}
variant="outline"
className="cursor-pointer hover:bg-accent"
onClick={() => update({ n: v })}
>
{v}
</Badge>
))}
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Minute past the hour</Label>
<Input
type="number"
min={0}
max={59}
className="w-24 font-mono"
value={state.minutePast}
onChange={(e) => update({ minutePast: clamp(+e.target.value || 0, 0, 59) })}
/>
</div>
<WeekdaysOnlyToggle state={state} update={update} />
</div>
)}
{preset === "daily" && (
<div className="space-y-1.5">
<Label className="text-xs">Times of day</Label>
<TimeList times={state.times} onChange={(times) => update({ times })} />
{state.times.length > 1 && (
<p className="text-xs text-muted-foreground">
All times in one schedule share the same minute. Changing one minute updates them all.
</p>
)}
</div>
)}
{preset === "weekdays" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Days of the week</Label>
<DayOfWeekPicker days={state.days} onChange={(days) => update({ days })} />
<div className="flex flex-wrap gap-1.5 pt-1">
{(
[
["Weekdays", [1, 2, 3, 4, 5]],
["Weekends", [0, 6]],
["All days", [0, 1, 2, 3, 4, 5, 6]],
["Mon · Wed · Fri", [1, 3, 5]],
["Tue · Thu", [2, 4]],
] as const
).map(([label, days]) => (
<Badge
key={label}
variant="outline"
className="cursor-pointer hover:bg-accent"
onClick={() => update({ days: [...days] })}
>
{label}
</Badge>
))}
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Times of day</Label>
<TimeList times={state.times} onChange={(times) => update({ times })} />
{state.times.length > 1 && (
<p className="text-xs text-muted-foreground">
All times in one schedule share the same minute. Changing one minute updates them all.
</p>
)}
</div>
</div>
)}
{preset === "monthly" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Days of the month</Label>
<DayOfMonthPicker domDays={state.domDays} onChange={(domDays) => update({ domDays })} />
<div className="flex flex-wrap gap-1.5 pt-1">
{(
[
["1st only", [1]],
["15th only", [15]],
["1st & 15th", [1, 15]],
["Last day (28th)", [28]],
] as const
).map(([label, days]) => (
<Badge
key={label}
variant="outline"
className="cursor-pointer hover:bg-accent"
onClick={() => update({ domDays: [...days] })}
>
{label}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Days 2931 are skipped in months that don't have them.
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Times of day</Label>
<TimeList times={state.times} onChange={(times) => update({ times })} />
{state.times.length > 1 && (
<p className="text-xs text-muted-foreground">
All times in one schedule share the same minute. Changing one minute updates them all.
</p>
)}
</div>
</div>
)}
{preset === "custom" && (
<div className="space-y-1.5">
<Label className="text-xs">Cron expression</Label>
<Input
value={state.custom}
onChange={(e) => update({ custom: e.target.value })}
placeholder="0 10 * * *"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Five fields: minute hour day-of-month month day-of-week
</p>
</div>
)}
<Separator />
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="text-xs">
<span className="text-muted-foreground">Summary — </span>
<span className="font-medium">{describeSchedule(buildCronFromState(state))}</span>
</div>
<code className="text-xs font-mono text-muted-foreground">
{buildCronFromState(state)}
</code>
</div>
</div>
);
}
function WindowAndWeekdaysToggles({
state,
update,
}: {
state: EditorState;
update: (patch: Partial<EditorState>) => void;
}) {
return (
<>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox
checked={state.windowEnabled}
onCheckedChange={(checked) => update({ windowEnabled: checked === true })}
/>
<span>Only between certain hours</span>
</label>
{state.windowEnabled && (
<div className="flex items-center gap-2 pl-6 flex-wrap">
<Select
value={String(state.windowStart)}
onValueChange={(v) => {
const windowStart = Number(v);
update({
windowStart,
windowEnd: Math.max(state.windowEnd, windowStart),
});
}}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={String(i)}>
{pad(i)}:00
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">to</span>
<Select
value={String(state.windowEnd)}
onValueChange={(v) => update({ windowEnd: Math.max(Number(v), state.windowStart) })}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={String(i)} disabled={i < state.windowStart}>
{pad(i)}:00
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<WeekdaysOnlyToggle state={state} update={update} />
</>
);
}
function WeekdaysOnlyToggle({
state,
update,
}: {
state: EditorState;
update: (patch: Partial<EditorState>) => void;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox
checked={state.weekdaysOnly}
onCheckedChange={(checked) => update({ weekdaysOnly: checked === true })}
/>
<span>Weekdays only (MonFri)</span>
</label>
);
}
function changePreset(state: EditorState, next: EditorPreset): EditorState {
// Reset ambiguous sub-state when switching presets so we don't carry
// over a stale weekdaysOnly / windowEnabled from a sibling preset.
switch (next) {
case "every_minute":
return { ...state, preset: next };
case "every_n_minutes":
return { ...state, preset: next, n: 15, windowEnabled: false, weekdaysOnly: false };
case "hourly":
return { ...state, preset: next };
case "every_n_hours":
return { ...state, preset: next, n: 2, weekdaysOnly: false };
case "daily":
return { ...state, preset: next, times: state.times.length ? state.times : ["09:00"] };
case "weekdays":
return {
...state,
preset: next,
days: state.days.length ? state.days : [1, 2, 3, 4, 5],
times: state.times.length ? state.times : ["09:00"],
};
case "monthly":
return {
...state,
preset: next,
domDays: state.domDays.length ? state.domDays : [1],
times: state.times.length ? state.times : ["09:00"],
};
case "custom":
return { ...state, preset: next, custom: state.custom || buildCronFromState(state) };
}
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(Math.max(v, lo), hi);
}
export function getScheduleEditorPresetForTest(cron: string): EditorPreset {
return parseCronToEditorState(cron).preset;
}
export function hasSingleMinuteAcrossTimesForTest(times: string[]): boolean {
return hasSingleMinuteAcrossTimes(times);
}
export function roundTripCronForTest(cron: string): string {
return buildCronFromState(parseCronToEditorState(cron));
}