Files
codecraft/web/src/components/CronBuilder.tsx
T
Richard ZinkandClaude Opus 5.5 05c215f685 Automation editor with graphical schedule and assistant; movable chat list groups
Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
  mode and time limit are settings in the database (jobs.settings). Existing
  files are adopted on start-up (frontmatter into the database and removed from
  the file); plain Markdown files registered later get default settings and stay
  switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
  a template) create .codecraft/jobs/<name>.md and open the editor: settings with
  a graphical cron builder (next runs in server time via /jobs/schedule), the
  instructions in the Markdown editor and an assistant chat
  (POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
  after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.

Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
  drop, or the group's menu), stored per user via /account/sidebar. A new
  message moves the chat to the top of its group, no longer the project itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:05:15 +01:00

218 lines
8.2 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 { useEffect, useState } from "react";
import { get } from "../api";
import { getLang, useT } from "../i18n";
/**
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
* custom expression. Shows the next runs as computed by the server.
*/
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
interface State {
mode: Mode;
every: number;
minute: number;
time: string; // HH:MM
days: number[]; // cron weekdays, 0 = Sunday
dom: number;
custom: string;
}
const MINUTE_STEPS = [5, 10, 15, 20, 30];
/** Monday first; cron numbers (0 = Sunday). */
const WEEK = [1, 2, 3, 4, 5, 6, 0];
const pad = (n: number) => String(n).padStart(2, "0");
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
function expandDays(field: string): number[] | null {
const out = new Set<number>();
for (const part of field.split(",")) {
const m = part.match(/^(\d)(?:-(\d))?$/);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (a > 7 || b > 7 || b < a) return null;
for (let d = a; d <= b; d++) out.add(d % 7);
}
return [...out];
}
export function parseCron(expr: string): State {
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
const f = expr.trim().split(/\s+/);
if (f.length !== 5) return base;
const [mi, h, dom, mon, dow] = f;
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
if (mon !== "*") return base;
let m;
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
if (!num(mi, 59) || !num(h, 23)) return base;
const time = hhmm(h, mi);
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
if (dom === "*") {
const days = expandDays(dow);
return days?.length ? { ...base, mode: "weekly", time, days } : base;
}
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
return base;
}
export function buildCron(s: State): string {
const [h, m] = s.time.split(":").map(Number);
switch (s.mode) {
case "minutes":
return `*/${s.every} * * * *`;
case "hourly":
return `${s.minute} * * * *`;
case "daily":
return `${m} ${h} * * *`;
case "weekly": {
const days = [...s.days].sort((a, b) => a - b);
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
}
case "monthly":
return `${m} ${h} ${s.dom} * *`;
default:
return s.custom.trim();
}
}
const dayName = (d: number, style: "short" | "long") => {
// 2023-01-01 was a Sunday.
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
};
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
const fmtWall = (s: string) => {
const [d, hm] = s.split("T");
const [y, mo, da] = d.split("-").map(Number);
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
return `${date}, ${hm}`;
};
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
const t = useT();
const [s, setS] = useState<State>(() => parseCron(value));
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
// A value from outside (another automation, reset) that the builder did not produce.
useEffect(() => {
if (value.trim() !== buildCron(s)) setS(parseCron(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
const expr = value.trim();
if (!expr) return setPreview(undefined);
let alive = true;
const h = setTimeout(() => {
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
.then((p) => alive && setPreview(p))
.catch(() => alive && setPreview(undefined));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [value]);
const set = (patch: Partial<State>) => {
const next = { ...s, ...patch };
// Switching to "custom" starts from the current expression.
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
setS(next);
onChange(buildCron(next));
};
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
return (
<div className="cron-builder">
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
{modes.map((m) => (
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
{t(`cron.mode.${m}` as const)}
</button>
))}
</div>
<div className="cron-fields">
{s.mode === "minutes" && (
<label>
{t("cron.every")}
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
{MINUTE_STEPS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{t("cron.minutes")}
</label>
)}
{s.mode === "hourly" && (
<label>
{t("cron.atMinute")}
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
</label>
)}
{s.mode === "weekly" && (
<div className="cron-days" role="group" aria-label={t("cron.days")}>
{WEEK.map((d) => (
<button
key={d}
type="button"
aria-pressed={s.days.includes(d)}
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
title={dayName(d, "long")}
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
>
{dayName(d, "short")}
</button>
))}
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
{t("cron.workdays")}
</button>
</div>
)}
{s.mode === "monthly" && (
<label>
{t("cron.onDay")}
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
<label>
{t("cron.at")}
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
</label>
)}
{s.mode === "custom" && (
<label className="cron-custom">
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
<small className="hint">{t("cron.customHint")}</small>
</label>
)}
</div>
<div className="cron-preview">
<code>{value || "–"}</code>
{preview?.ok === false && <span className="err">{preview.error}</span>}
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
<span>
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
</span>
)}
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
</div>
</div>
);
}