Let a conversation's menu show the routines that run in it

The header menu had Tasks but nothing for the schedules whose results
land in that conversation. It now has Schedules, right under Tasks,
opened in a modal that lists only what runs there: a bot's own chat holds
the routines that run nowhere, a workspace only that bot's routines in
it (`routineScopeSql`, tested against SQLite).

The list loads when the modal opens, through
`api/admin/bots/list-routines`, which checks the user chats in that
conversation first, so the page props stay small. `grabCronJobs` gained
an optional `workspace_id`: left out it still means everywhere.

The routines page's row, form and edit modal moved to
`src/components/general/`, and its subtitle to `src/utils/`, since both
the page and the modal use them.
This commit is contained in:
2026-10-23 07:23:15 +01:00
parent 796c53716f
commit 5beb5f3de4
17 changed files with 566 additions and 18 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import type { CronJobWithNames } from "@/src/types";
import { dropRoutine, replaceRoutine } from "./routine-list-changes";
function routine({ id, title }: { id: number | string; title: string }): CronJobWithNames {
return { ...({ id, title, enabled: 1, schedule: "0 9 * * *" } as CronJobWithNames) };
}
const routines = [routine({ id: 1, title: "Morning" }), routine({ id: 2, title: "Evening" })];
describe("replaceRoutine", () => {
test("swaps in the saved version in the same place", () => {
const saved = routine({ id: 2, title: "Evening, paused" });
const next = replaceRoutine({ routines, cron_job: saved });
expect(next.map((item) => item.id)).toEqual([1, 2]);
expect(next[1]!.title).toBe("Evening, paused");
});
test("matches a string id against a numeric one", () => {
const next = replaceRoutine({
routines: [routine({ id: "1", title: "Morning" }), routine({ id: 2, title: "Evening" })],
cron_job: routine({ id: 1, title: "Morning, paused" }),
});
expect(next[0]!.title).toBe("Morning, paused");
expect(next.length).toBe(2);
});
test("leaves every other routine alone", () => {
expect(replaceRoutine({ routines, cron_job: routine({ id: 99, title: "Not here" }) })).toEqual(routines);
});
test("does not change the list it was given", () => {
const copy = [...routines];
replaceRoutine({ routines, cron_job: routine({ id: 1, title: "Changed" }) });
expect(routines).toEqual(copy);
});
});
describe("dropRoutine", () => {
test("removes the deleted one and keeps the rest", () => {
expect(dropRoutine({ routines, cron_job_id: 1 }).map((item) => item.id)).toEqual([2]);
});
test("matches a string id", () => {
expect(dropRoutine({ routines, cron_job_id: "2" }).map((item) => item.id)).toEqual([1]);
});
test("an id that isn't there changes nothing", () => {
expect(dropRoutine({ routines, cron_job_id: 42 })).toEqual(routines);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { CronJobWithNames } from "@/src/types";
/**
* Replaces one routine in a list with its saved version, keeping the order.
* Only the row that changed moves, so a pause or an edit never reshuffles
* the list under the pointer.
*/
export function replaceRoutine({
routines,
cron_job,
}: {
routines: CronJobWithNames[];
cron_job: CronJobWithNames;
}): CronJobWithNames[] {
return routines.map((item) => (item.id == cron_job.id ? cron_job : item));
}
/**
* Drops a deleted routine from a list.
*/
export function dropRoutine({
routines,
cron_job_id,
}: {
routines: CronJobWithNames[];
cron_job_id: string | number | undefined;
}): CronJobWithNames[] {
return routines.filter((item) => item.id != cron_job_id);
}
@@ -0,0 +1,44 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import routineScopeSql from "./routine-scope-sql";
import type { SqlCondition } from "./types";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE cron_jobs (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
db.run(`INSERT INTO cron_jobs VALUES
(1, 2, NULL),
(2, 2, 10),
(3, 2, 11),
(4, 3, NULL),
(5, 3, 10)`);
});
function ids({ condition }: { condition: SqlCondition }) {
return db
.query(`SELECT c.id FROM cron_jobs c WHERE ${condition.sql} ORDER BY c.id`)
.all(...condition.values)
.map((row) => (row as { id: number }).id);
}
describe("routineScopeSql", () => {
test("a bot's own chat holds only the routines that run nowhere", () => {
expect(ids({ condition: routineScopeSql({ bot_id: 2 }) })).toEqual([1]);
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: null }) })).toEqual([4]);
});
test("a workspace holds only that bot's routines there", () => {
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 10 }) })).toEqual([2]);
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: 10 }) })).toEqual([5]);
});
test("another bot's routines in the same workspace are not this conversation's", () => {
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 11 }) })).toEqual([3]);
expect(ids({ condition: routineScopeSql({ bot_id: 9, workspace_id: 11 }) })).toEqual([]);
});
test("a string id from a query string still matches", () => {
expect(ids({ condition: routineScopeSql({ bot_id: "2", workspace_id: "10" }) })).toEqual([2]);
});
});
@@ -0,0 +1,25 @@
import type { RoutineScopeSqlParams, SqlCondition } from "./types";
/**
* The routines of one conversation: that bot's schedules in that workspace,
* or, for its own chat, the ones that run nowhere (a routine made there
* stays there even if the bot later joins a workspace). `workspace_id` is
* NULL in SQLite rather than 0, so the two cases can't share a comparison.
*/
export default function routineScopeSql({
bot_id,
workspace_id,
alias = "c",
}: RoutineScopeSqlParams): SqlCondition {
if (workspace_id) {
return {
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id = ?)`,
values: [Number(bot_id), Number(workspace_id)],
};
}
return {
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)`,
values: [Number(bot_id)],
};
}
+23
View File
@@ -0,0 +1,23 @@
import type { SqlCondition } from "@/src/utils/task-access/types";
/**
* The conversation a routines list belongs to: a bot's own chat (no
* workspace), or that bot's conversation in one workspace.
*/
export type RoutineConversationScope = {
bot_id: number | string;
/**
* Null or undefined for the bot's own chat. A workspace id keeps only
* the routines running there.
*/
workspace_id?: number | string | null;
};
export type RoutineScopeSqlParams = RoutineConversationScope & {
/**
* The table alias the condition reads.
*/
alias?: string;
};
export type { SqlCondition };
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import routineSubtitle from "./routine-subtitle";
const now = Date.UTC(2026, 9, 19, 8, 30);
describe("routineSubtitle", () => {
test("names the bot, workspace, schedule and next run", () => {
expect(
routineSubtitle({
cron_job: {
bot_name: "Scout",
workspace_name: "Launch",
schedule: "0 9 * * *",
enabled: 1,
next_run_at: now + 30 * 60 * 1000,
},
now,
}),
).toBe("Scout · Launch · 0 9 * * * UTC · next in 30m");
});
test("shows the last error instead, and no next run when paused", () => {
expect(
routineSubtitle({
cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now, last_error: "Skipped a run." },
now,
}),
).toBe("Scout · @daily UTC · Skipped a run.");
expect(routineSubtitle({ cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now }, now })).toBe(
"Scout · @daily UTC",
);
});
test("leaves out the bot and workspace in a conversation's own modal", () => {
expect(
routineSubtitle({
cron_job: {
bot_name: "Scout",
workspace_name: "Launch",
schedule: "0 9 * * *",
enabled: 1,
next_run_at: now + 30 * 60 * 1000,
},
now,
show_bot: false,
show_workspace: false,
}),
).toBe("0 9 * * * UTC · next in 30m");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { CronJobWithNames } from "@/src/types";
import timeUntil from "./time-until";
type Params = {
cron_job: CronJobWithNames;
now?: number;
/**
* Shown by default; left out where the conversation already is the
* bot's (or the workspace's).
*/
show_bot?: boolean;
show_workspace?: boolean;
};
/**
* The line under a routine's name: whose it is, where it runs, its
* schedule, then when it runs next or why it last couldn't.
*/
export default function routineSubtitle({
cron_job,
now = Date.now(),
show_bot = true,
show_workspace = true,
}: Params) {
const next =
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
return [
show_bot ? cron_job.bot_name || "A bot" : "",
show_workspace ? cron_job.workspace_name || "" : "",
`${cron_job.schedule} UTC`,
cron_job.last_error || next,
]
.filter(Boolean)
.join(" · ");
}