"use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; type TaskType = "say" | "backup" | "snapshot-prune"; type Task = { id: string; type: TaskType; label: string; cron: string; params: { message?: string; keep?: number }; }; type Preset = | { kind: "daily"; hour: number; minute: number } | { kind: "every-hours"; hours: number }; function cronFromPreset(p: Preset): string { if (p.kind === "daily") return `${p.minute} ${p.hour} * * *`; return `0 */${p.hours} * * *`; } function describeCron(cron: string): string { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return cron; const [m, h, , , dow] = parts; if (dow === "*" && h.startsWith("*/")) { return `every ${h.slice(2)}h`; } if (dow === "*" && /^\d+$/.test(h) && /^\d+$/.test(m)) { return `daily at ${h.padStart(2, "0")}:${m.padStart(2, "0")}`; } return cron; } function nextRun(cron: string): Date | null { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return null; const [m, h] = parts; const now = new Date(); if (/^\d+$/.test(h) && /^\d+$/.test(m)) { const d = new Date(now); d.setHours(parseInt(h), parseInt(m), 0, 0); if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1); return d; } const hMatch = h.match(/^\*\/(\d+)$/); const mm = /^\d+$/.test(m) ? parseInt(m) : 0; if (hMatch) { const step = parseInt(hMatch[1]); const d = new Date(now); d.setMinutes(mm, 0, 0); while (d.getTime() <= now.getTime() || d.getHours() % step !== 0) { d.setHours(d.getHours() + 1); } return d; } return null; } function relative(d: Date | null): string { if (!d) return "—"; const diff = d.getTime() - Date.now(); if (diff <= 0) return "soon"; const min = Math.round(diff / 60000); if (min < 60) return `in ${min}m`; const hr = Math.floor(min / 60); const rem = min % 60; if (hr < 24) return `in ${hr}h${rem ? ` ${rem}m` : ""}`; const day = Math.floor(hr / 24); return `in ${day}d`; } export function ScheduledTasks() { const queryClient = useQueryClient(); const [adding, setAdding] = useState(false); const [form, setForm] = useState({ type: "say" as TaskType, message: "", keep: 5, preset: "daily" as "daily" | "every-hours", hour: 4, minute: 0, hours: 6, }); const { data: tasks = [], isLoading } = useQuery({ queryKey: ["schedule-tasks"], queryFn: () => fetch("/api/schedule/tasks").then((r) => r.json()), staleTime: 30_000, }); const create = useMutation({ mutationFn: async () => { const cron = cronFromPreset( form.preset === "daily" ? { kind: "daily", hour: form.hour, minute: form.minute } : { kind: "every-hours", hours: form.hours } ); const body: { type: TaskType; cron: string; label: string; params: Record } = { type: form.type, cron, label: "", params: {}, }; if (form.type === "say") body.params.message = form.message.trim(); if (form.type === "snapshot-prune") body.params.keep = form.keep; const res = await fetch("/api/schedule/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data as Task; }, onSuccess: () => { toast.success("Task scheduled"); setAdding(false); setForm((f) => ({ ...f, message: "" })); queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); }, onError: (err) => toast.error("Failed to add task", { description: err.message }), }); const remove = useMutation({ mutationFn: async (id: string) => { const res = await fetch("/api/schedule/tasks", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; }, onSuccess: () => { toast.success("Task removed"); queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); }, onError: (err) => toast.error("Remove failed", { description: err.message }), }); const canSubmit = useMemo(() => { if (form.type === "say") { const re = /^[^\r\n$`\\'"]{1,120}$/; return re.test(form.message.trim()); } if (form.type === "snapshot-prune") { return form.keep >= 1 && form.keep <= 50; } return true; }, [form]); const pad = (n: number) => String(n).padStart(2, "0"); return (

Scheduled Tasks

Recurring chat messages, backups, and snapshot pruning (via crontab)

{!adding && ( )}
{adding && (
{(["say", "backup", "snapshot-prune"] as const).map((t) => ( ))}
{form.type === "say" && ( setForm((f) => ({ ...f, message: e.target.value }))} maxLength={120} className="h-9 text-sm" /> )} {form.type === "snapshot-prune" && (
setForm((f) => ({ ...f, keep: parseInt(e.target.value) || 5 })) } className="h-9 text-sm w-20" />
)}
{(["daily", "every-hours"] as const).map((p) => ( ))}
{form.preset === "daily" ? ( <> : ) : ( )}
)} {isLoading ? (

Loading...

) : tasks.length === 0 ? (

No scheduled tasks yet.

) : (
    {tasks.map((t) => (
  • {t.label} {t.type}

    {describeCron(t.cron)} · next {relative(nextRun(t.cron))}

  • ))}
)}
); }