Files
LLeMbas-Desktop/ui/js/scheduled.js
T
HomerandClaude Opus 5.5 ad36932c9f
ci / check (push) Failing after 2s
ci / start (push) Failing after 3s
Scheduled: the list, a task's page with its history and Run now, a new one said in words
Built against the scheduling plan's /api/v2 routes (GET/POST /schedules, /schedules/compile,
PATCH/DELETE /schedules/{id}, POST /schedules/{id}/run); a server without them answers 404 and
the page says so. A new task is words, the server's reading of them (the rule in words, the
instruction, the next five runs), then Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:58:52 +00:00

393 lines
16 KiB
JavaScript
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.
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
// task's page (what it does, when, how it tells, its history, Run now), and a new one said in
// words: the server turns them into a rule and a standalone instruction, the next five runs are
// shown, and only then is it saved. The server runs them; this page only reads and asks.
//
// Its routes are the server's /api/v2 for scheduling:
//
// GET /schedules { schedules: [Schedule] }
// GET /schedules/{id} { schedule, runs: [Run], more }
// POST /schedules/compile { text } -> { title, instruction, summary, rule, next: [stamp] }
// POST /schedules { request, title, instruction, rule, model?, notify?, mode? } -> { schedule }
// PATCH /schedules/{id} { enabled?, notify?, mode?, model? } -> { schedule }
// POST /schedules/{id}/run -> { run }
// DELETE /schedules/{id}
//
// A server without them answers 404: the page says this server has no scheduled tasks yet.
// Everything a task says (its title, instruction, a run's error) is the person's or a model's
// words, set as text.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { get, post, patch, del, why } from "./bridge.js"
import { ask, menu } from "./popup.js"
import { avatar } from "./models.js"
let ctx = null // { status(), models(), me(), openChat(id), show() }
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "new" }
let tasks = []
let missing = false // the server has no scheduling routes
const STATUS = {
ok: () => t("Done"),
silent: () => t("Nothing to report"),
failed: () => t("Failed"),
missed: () => t("Missed"),
skipped: () => t("Skipped"),
waiting: () => t("Waiting for a free model"),
running: () => t("Running"),
}
const NOTIFY = {
always: () => t("Always"),
matters: () => t("When it matters"),
never: () => t("Never"),
}
const MODE = {
fresh: () => t("A new chat for each run"),
continue: () => t("One chat that grows"),
}
const enc = encodeURIComponent
function statusChip(run) {
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
const word = STATUS[run.status]?.() || run.status
const n = run.status === "missed" && run.missed > 1 ? ` ×${run.missed}` : ""
return h("span", { class: `schip is-${run.status}`, text: `${word}${n}` })
}
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
function stamp(value) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ""
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
}
function nextWords(task) {
if (!task.enabled) return t("Paused")
if (!task.next_run_at) return t("No more runs")
return `${when(task.next_run_at)} · ${stamp(task.next_run_at)}`
}
// --- Reading ------------------------------------------------------------------------------------
async function load() {
try {
tasks = (await get("/schedules")).schedules || []
missing = false
} catch (err) {
tasks = []
missing = err?.status === 404 || err?.status === 405
if (!missing) throw err
}
}
/** Show the page: the list, or one task's page (`id`). */
export async function showScheduled(id) {
view = id ? { name: "task", id } : { name: "list" }
await draw()
}
async function draw() {
const body = clear($("scheduled-body"))
clear($("scheduled-tools"))
clear($("scheduled-place"))
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
try {
if (view.name === "task") await drawTask(view.id)
else if (view.name === "new") drawNew()
else {
await load()
drawList()
}
} catch (err) {
clear(body).append(h("p", { class: "status is-error", text: why(err) }))
}
}
/** The page's title; with a way back, "Scheduled ›" before it. */
function header(title, back) {
const h1 = $("scheduled-title")
if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title }))
else h1.textContent = title
}
// --- The list -------------------------------------------------------------------------------------
function drawList() {
header(t("Scheduled"))
const tools = clear($("scheduled-tools"))
const body = clear($("scheduled-body"))
if (missing) {
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
return
}
tools.append(h("button", { type: "button", class: "btn btn--sm btn--primary", onclick: () => go({ name: "new" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })))
if (!tasks.length) {
body.append(
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "new" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
)
return
}
const list = h("ul", { class: "tasks-list", role: "list" })
for (const task of tasks) list.append(taskRow(task))
body.append(list)
}
function empty(title, text) {
return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text }))
}
/** One task: icon · title and its rule in words · next run · last status · pause switch. */
function taskRow(task) {
const toggle = h("input", {
type: "checkbox",
role: "switch",
checked: !!task.enabled,
"aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }),
title: task.enabled ? t("Pause") : t("Resume"),
onclick: (e) => e.stopPropagation(),
onchange: (e) => setEnabled(task, e.target.checked),
})
const row = h(
"li",
{ class: `task${task.enabled ? "" : " is-paused"}` },
h(
"button",
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
h("span", { class: "task__icon" }, icon(ICONS.clock)),
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.summary || "" })),
h("span", { class: "task__next" }, h("span", { class: "task__label", text: t("Next run") }), h("span", { class: "task__value", text: nextWords(task) })),
h("span", { class: "task__last" }, statusChip(task.last_run)),
),
h("label", { class: "toggle task__toggle" }, toggle),
)
return row
}
async function setEnabled(task, on) {
try {
const r = await patch(`/schedules/${enc(task.id)}`, { enabled: on })
Object.assign(task, r.schedule || { enabled: on })
} catch (err) {
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
if (view.name === "list") drawList()
else draw()
}
function go(next) {
view = next
draw()
}
// --- One task -----------------------------------------------------------------------------------
async function drawTask(id) {
const r = await get(`/schedules/${enc(id)}`)
const task = r.schedule
const runs = r.runs || []
header(task.title || t("Scheduled"), () => go({ name: "list" }))
const tools = clear($("scheduled-tools"))
tools.append(
h("button", { type: "button", class: "btn btn--sm btn--primary", onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
h("button", {
type: "button",
class: "tool",
"aria-label": t("More"),
title: t("More"),
onclick: (e) =>
menu(e.currentTarget, [
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
null,
{ label: t("Delete"), danger: true, run: () => remove(task) },
]),
}, icon(ICONS.more)),
)
const models = ctx.models()
const model = models.find((m) => m.id === task.model)
const body = clear($("scheduled-body"))
body.append(
h(
"section",
{ class: "sched" },
h(
"div",
{ class: "sched__facts" },
fact(t("When"), h("span", { text: task.summary || "" })),
fact(t("Next run"), h("span", { text: nextWords(task) })),
fact(t("Last run"), statusChip(task.last_run)),
fact(t("Model"), model ? h("span", { class: "sched__model" }, avatar(model), h("span", { text: model.name })) : h("span", { text: task.model || t("default") })),
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
),
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
upcoming(task.next || []),
),
history(runs),
)
}
function fact(label, value) {
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value))
}
function upcoming(next) {
if (!next.length) return null
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
}
/** The history: one row per run on fixed tracks -- status, when, model, and its result. */
function history(runs) {
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
if (!runs.length) {
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
return box
}
const models = ctx.models()
const list = h("ul", { class: "runs" })
for (const run of runs) {
const m = models.find((x) => x.id === run.model)
list.append(
h(
"li",
{ class: "run" },
h("span", { class: "run__status" }, statusChip(run)),
h("span", { class: "run__when", title: run.started_at || run.due_at || "" }, h("span", { text: stamp(run.started_at || run.due_at) }), run.timing === "late" || run.timing === "catch_up" ? h("span", { class: "run__late", text: run.timing === "late" ? t("late") : t("caught up") }) : null),
h("span", { class: "run__model", title: run.model_reason || "" }, m ? avatar(m) : null, h("span", { text: m?.name || run.model || "" })),
run.chat_id
? h("button", { type: "button", class: "btn btn--sm", text: t("Open the result"), onclick: () => ctx.openChat(run.chat_id) })
: h("span", { class: "run__error", title: run.error || "", text: run.error || "" }),
),
)
}
box.append(list)
return box
}
async function runNow(task, btn) {
btn.disabled = true
try {
await post(`/schedules/${enc(task.id)}/run`)
await draw()
} catch (err) {
btn.disabled = false
await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
}
async function remove(task) {
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/schedules/${enc(task.id)}`)
go({ name: "list" })
} catch (err) {
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
}
// --- A new task, said in words ------------------------------------------------------------------
let compiled = null // the server's reading of the words: { title, instruction, summary, rule, next }
function drawNew() {
header(t("New scheduled task"), () => go({ name: "list" }))
clear($("scheduled-tools"))
compiled = null
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "4", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when") })
const said = h("p", { class: "status is-error", hidden: true })
const preview = h("div", { class: "sched__preview", hidden: true })
const read = h("button", { type: "submit", class: "btn btn--primary", text: t("Preview") })
const form = h(
"form",
{ class: "sched sched--new" },
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
words,
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
h("div", { class: "set__buttons" }, read),
said,
preview,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
const text = words.value.trim()
if (!text) return words.focus()
read.disabled = true
said.hidden = true
try {
compiled = await post("/schedules/compile", { text })
drawPreview(preview, text)
} catch (err) {
said.textContent = why(err)
said.hidden = false
} finally {
read.disabled = false
}
})
words.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
clear($("scheduled-body")).append(form)
words.focus()
}
/** What the server made of the words: the rule in words, the instruction, the next five runs. */
function drawPreview(box, text) {
const c = compiled
box.hidden = false
const choice = { notify: "matters", mode: "fresh" }
const select = (key, table) =>
h(
"select",
{ "aria-label": key === "notify" ? t("Tell me") : t("Runs"), onchange: (e) => (choice[key] = e.target.value) },
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: choice[key] === v })),
)
const save = h("button", { type: "button", class: "btn btn--primary", text: t("Save") })
const said = h("p", { class: "status is-error", hidden: true })
save.addEventListener("click", async () => {
save.disabled = true
try {
const r = await post("/schedules", { request: text, title: c.title, instruction: c.instruction, rule: c.rule, notify: choice.notify, mode: choice.mode })
go({ name: "task", id: r.schedule.id })
} catch (err) {
said.textContent = why(err)
said.hidden = false
save.disabled = false
}
})
box.replaceChildren(
h(
"div",
{ class: "sched__card" },
h("p", { class: "sched__title", text: c.title || "" }),
h("div", { class: "sched__facts" }, fact(t("When"), h("span", { text: c.summary || "" })), fact(t("What it does"), h("span", { text: c.instruction || "" }))),
upcoming(c.next || []) || h("p", { class: "status is-error", text: t("These words give no run in the future.") }),
h("div", { class: "sched__facts" }, fact(t("Tell me"), select("notify", NOTIFY)), fact(t("Runs"), select("mode", MODE))),
said,
h("div", { class: "set__buttons" }, save),
),
)
save.focus()
}
/** The palette's commands for scheduled tasks. */
export function scheduledCommands() {
return [
{
label: t("New scheduled task"),
run: () => {
ctx.show()
go({ name: "new" })
},
},
]
}
export function initScheduled(context) {
ctx = context
}