lazyBoy/apps/web/src/schedule.tsx

199 lines
8.7 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 { t } from "./i18n";
export type CronFreq = "Every hour" | "Every day" | "Weekdays" | "Every week" | "Every month" | "Interval" | "Advanced";
export type CronUnit = "minutes" | "hours" | "days";
export type CronPreset = { freq: CronFreq; n: number; unit: CronUnit; time: string; cron: string };
export interface ScheduleItem {
id: string;
botId: string;
threadId?: string | null;
name: string;
cron: string;
timezone: string;
instructions: string;
enabled: boolean;
human: string;
lastRunAt?: string | null;
nextRunAt?: string | null;
}
export const CRON_FREQS: CronFreq[] = ["Every hour", "Every day", "Weekdays", "Every week", "Every month", "Interval", "Advanced"];
const TIMES = ["6:00 AM", "7:00 AM", "8:00 AM", "9:00 AM", "12:00 PM", "3:00 PM", "6:00 PM", "9:00 PM"];
const NUMBERS = [1, 2, 3, 5, 10, 15, 30, 45];
const TIMED: CronFreq[] = ["Every day", "Weekdays", "Every week", "Every month"];
export function defaultCronPreset(): CronPreset {
return { freq: "Every day", n: 3, unit: "minutes", time: "9:00 AM", cron: "" };
}
export function cronFromPreset(input: CronPreset): string {
if (input.freq === "Advanced") return input.cron.trim();
if (input.freq === "Every hour") return "0 * * * *";
if (input.freq === "Interval") {
if (!Number.isInteger(input.n) || input.n < 1 || input.n > 365) throw new Error("間隔必須為 1365 的整數");
return `@every ${input.n}${({minutes:"m",hours:"h",days:"d"})[input.unit]}`;
}
const { hour, minute } = parseClock(input.time);
if (input.freq === "Weekdays") return `${minute} ${hour} * * 1-5`;
if (input.freq === "Every week") return `${minute} ${hour} * * 1`;
if (input.freq === "Every month") return `${minute} ${hour} 1 * *`;
return `${minute} ${hour} * * *`;
}
export function presetFromCron(cron: string): CronPreset {
const base = defaultCronPreset();
const interval = /^@every ([1-9]\d{0,2})([mhd])$/.exec(cron.trim());
if (interval && Number(interval[1]) <= 365) return {...base, freq:"Interval", n:Number(interval[1]), unit:({m:"minutes",h:"hours",d:"days"} as const)[interval[2] as "m"|"h"|"d"]};
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return { ...base, freq: "Advanced", cron };
const [minute, hour, day, month, dow] = parts;
if (month !== "*") return { ...base, freq: "Advanced", cron };
if (minute === "0" && hour === "*" && day === "*" && dow === "*") return { ...base, freq: "Every hour" };
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return { ...base, freq: "Advanced", cron };
if (Number(hour) > 23 || Number(minute) > 59) return {...base, freq:"Advanced", cron};
const time = formatClock(Number(hour), Number(minute));
if (day === "*" && dow === "1-5") return { ...base, freq: "Weekdays", time };
if (day === "*" && dow === "1") return { ...base, freq: "Every week", time };
if (day === "1" && dow === "*") return { ...base, freq: "Every month", time };
if (day === "*" && dow === "*") return { ...base, freq: "Every day", time };
return { ...base, freq: "Advanced", cron };
}
function parseClock(time: string): { hour: number; minute: number } {
const [rawH, rest] = time.split(":");
const minute = Number((rest ?? "00").slice(0, 2));
let hour = Number(rawH);
if (/pm/i.test(time) && hour < 12) hour += 12;
if (/am/i.test(time) && hour === 12) hour = 0;
return { hour, minute };
}
function formatClock(hour: number, minute: number): string {
const period = hour >= 12 ? "PM" : "AM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:${String(minute).padStart(2, "0")} ${period}`;
}
function freqLabel(freq: CronFreq): string {
return ({
"Every hour": t("schedEveryHour"),
"Every day": t("schedEveryDay"),
"Weekdays": t("schedWeekdays"),
"Every week": t("schedEveryWeek"),
"Every month": t("schedEveryMonth"),
"Interval": t("schedInterval"),
"Advanced": t("schedAdvanced"),
})[freq];
}
export function ScheduleList({
items,
onCreate,
onOpen,
onRun,
runningId,
}: {
items: ScheduleItem[];
onCreate: () => void;
onOpen: (item: ScheduleItem) => void;
onRun: (item: ScheduleItem) => void;
runningId?: string | null;
}) {
return (
<div className="sched-list">
<div className="sched-list-head">
<span>{t("schedules")}</span>
<button type="button" className="icon-button" title={t("schedCreate")} onClick={onCreate}>+</button>
</div>
{items.length === 0 ? <p className="sched-empty">{t("schedEmpty")}</p> : items.map(item => (
<div className="sched-row" key={item.id}>
<button type="button" className="sched-row-main" onClick={() => onOpen(item)}>
<i className={`sched-dot ${item.enabled ? "on" : "off"}`} />
<span>
<strong>{item.name}</strong>
<small>{item.enabled ? item.human : t("schedPaused")}</small>
</span>
</button>
<button type="button" className="outline" disabled={runningId === item.id} onClick={() => onRun(item)}>
{runningId === item.id ? t("schedRunning") : t("schedRunNow")}
</button>
</div>
))}
</div>
);
}
export function ScheduleEditor({
draft,
timezone,
saving,
error,
onChange,
onBack,
onSave,
onDelete,
}: {
draft: { name: string; instructions: string; enabled: boolean; preset: CronPreset };
timezone: string;
saving: boolean;
error: string | null;
onChange: (next: { name: string; instructions: string; enabled: boolean; preset: CronPreset }) => void;
onBack: () => void;
onSave: () => void;
onDelete?: () => void;
}) {
const preset = draft.preset;
const times = TIMES.includes(preset.time) ? TIMES : [...TIMES, preset.time];
const numbers = NUMBERS.includes(preset.n) ? NUMBERS : [...NUMBERS, preset.n].sort((a, b) => a - b);
function patchPreset(partial: Partial<CronPreset>) {
onChange({ ...draft, preset: { ...preset, ...partial } });
}
return (
<div className="sched-editor">
<div className="sched-editor-head">
<button type="button" className="outline" onClick={onBack}>{t("back")}</button>
<strong>{t("schedule")}</strong>
</div>
<label className="memory-toggle">
<input type="checkbox" checked={draft.enabled} onChange={e => onChange({ ...draft, enabled: e.target.checked })} />
{t("schedActive")}
</label>
<label>{t("name")}<input value={draft.name} onChange={e => onChange({ ...draft, name: e.target.value })} placeholder={t("schedNamePlaceholder")} /></label>
<label>{t("schedInstruction")}<textarea rows={4} value={draft.instructions} onChange={e => onChange({ ...draft, instructions: e.target.value })} placeholder={t("schedInstructionPlaceholder")} /></label>
<div className="sched-when">
<span>{t("schedWhen")} <small>{timezone}</small></span>
<div className="sched-card">
<select value={preset.freq} onChange={e => patchPreset({ freq: e.target.value as CronFreq })} aria-label={t("schedWhen")}>
{CRON_FREQS.map(freq => <option key={freq} value={freq}>{freqLabel(freq)}</option>)}
</select>
{preset.freq === "Interval" && <>
<select value={String(preset.n)} onChange={e => patchPreset({ n: Number(e.target.value) })}>
{numbers.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<select value={preset.unit} onChange={e => patchPreset({ unit: e.target.value as CronUnit })}>
<option value="minutes">{t("schedMinutes")}</option>
<option value="hours">{t("schedHours")}</option>
<option value="days">{t("schedDays")}</option>
</select>
</>}
{TIMED.includes(preset.freq) && (
<select value={preset.time} onChange={e => patchPreset({ time: e.target.value })}>
{times.map(time => <option key={time} value={time}>{time}</option>)}
</select>
)}
{preset.freq === "Advanced" && (
<input className="sched-cron" value={preset.cron} placeholder="0 9 * * 1-5" onChange={e => patchPreset({ cron: e.target.value })} />
)}
</div>
</div>
{preset.freq === "Interval" && <small className="memory-help"> 24 </small>}
{error && <div className="pane-error">{error}</div>}
<div className="pane-actions">
{onDelete && <button type="button" className="danger" disabled={saving} onClick={onDelete}>{t("delete")}</button>}
<button type="button" className="primary" disabled={saving || !draft.name.trim() || !draft.instructions.trim() || (preset.freq === "Advanced" && !preset.cron.trim())} onClick={onSave}>{saving ? t("saving") : t("save")}</button>
</div>
</div>
);
}