feat: manage routine tasks through Discord forms and recurrence controls

This commit is contained in:
syntaxbullet
2026-09-08 08:24:25 +02:00
parent 07a0f80fc0
commit ab2aa74eba
4 changed files with 195 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
// Global commands are required for bot DMs; guild-only registrations cannot run there.
export const commands = [
{ name: 'help', description: 'Show commands and how to use Minabot' },
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
{ name: 'today', description: 'View and complete today’s habits' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

View File

@@ -0,0 +1,123 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
async function click(reply: any, label: string, guild = false, values?: string[]) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild);
}
async function list(id: string, guild = false) {
const home = await f.run({ data: { name: 'tasks' } }, guild);
expect(home.data.flags).toBe(guild ? 64 : undefined);
return click(home, 'Choose a task habit', guild, [id]);
}
async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); }
async function setup() { return f.json('/habits', 'POST', { name: 'Morning', method: 'tasks' }, 201); }
for (const guild of [false, true]) test(`task add, rename, schedule and delete match website state in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture(); const habit = await setup();
const add = await click(await list(habit.id, guild), 'Add task', guild);
let detail = await f.run(submit(add.data.custom_id, { name: 'Stretch' }), guild);
let tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
expect(tasks).toHaveLength(1); expect(tasks[0].name).toBe('Stretch'); expect(tasks[0].schedule).toEqual({ type: 'daily' });
const taskId = tasks[0].id;
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${taskId}`, 'PUT', { done: true });
const rename = await click(await task(habit.id, taskId, guild), 'Rename task', guild);
detail = await f.run(submit(rename.data.custom_id, { name: 'Mobility' }), guild);
tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
expect(tasks[0].name).toBe('Mobility'); expect(tasks[0].id).toBe(taskId);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
const menu = await click(detail, 'Change task schedule', guild);
const form = await click(menu, 'Recurrence', guild, ['weekly']);
detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 });
const confirm = await click(detail, 'Delete task', guild);
expect(confirm.data.content).toContain('completion total may change');
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
await click(confirm, 'Confirm task deletion', guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).due).toBe(false);
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
});
test('task scheduling supports daily, selected weekdays, day intervals and overlap warnings', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Plan' }] }, 201);
const id = h.tasks[0].id;
let detail = await task(h.id, id);
let form = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['interval']);
detail = await f.run(submit(form.data.custom_id, { every: '3', anchor: '2026-09-01' }));
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' });
let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
detail = await click(menu, 'Select weekdays', false, ['2']);
expect(detail.data.content).toContain('never coincides');
menu = await click(detail, 'Change task schedule');
detail = await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
form = await click(detail, 'Rename task');
expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan');
});
test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Old task' }] }, 201);
const id = h.tasks[0].id;
const rename = await click(await task(h.id, id), 'Rename task');
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' });
expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed');
f.setDiscordTime('2026-09-05T12:00:00Z');
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0);
const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b');
const add = await click(await list(h.id), 'Add task');
expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another');
const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob);
expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another');
const aliceMenu = await f.run({ data: { name: 'tasks' } });
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
});
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
f = discordFixture(); const h = await setup();
const add = await click(await list(h.id), 'Add task');
const payload = f.payload(submit(add.data.custom_id, { name: 'Once' }));
await f.requestDiscord(payload); f.restart(); await f.requestDiscord(payload);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(1);
expect(f.changed).toHaveLength(1);
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201);
const first = await list(h.id);
expect(first.data.components[0].components[0].options).toHaveLength(10);
const next = await click(first, 'Next');
expect(next.data.components[0].components[0].options).toHaveLength(2);
expect(control(next, 'Previous')).toBeTruthy();
});
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
const h = await setup();
let add = await click(await list(h.id), 'Add task');
f.setDiscordTime('2026-09-04T22:01:00Z');
expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed');
add = await click(await list(h.id), 'Add task');
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' });
expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed');
const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
const home = await f.run({ data: { name: 'habits' } });
const detail = await click(home, 'Choose a habit to manage', false, [c.id]);
const form = await click(detail, 'Edit details');
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 });
expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed');
});
test('a complete checklist routine can be created entirely in Discord', async () => {
f = discordFixture();
const empty = await f.run({ data: { name: 'tasks' } }, true);
const methods = await click(empty, 'Create habit', true);
const form = await click(methods, 'Tracking method', true, ['tasks']);
const habit = await f.run(submit(form.data.custom_id, { name: 'Evening', color: '#abcdef' }), true);
const tasks = await click(habit, 'Manage tasks', true);
const add = await click(tasks, 'Add task', true);
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true);
expect(task.data.content).toContain('Prepare tomorrow');
const website = (await f.json('/habits')).habits[0];
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
const settings = await f.json(`/habits/${website.id}/calendar-settings`);
expect(settings.mainColor).toBe('#abcdef');
const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
const taskMenu = await list(website.id);
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]);
expect(blocked.data.content).toContain('available options');
});

View File

@@ -1,4 +1,4 @@
import { habitInput, habitPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { fields, type Context, type ControlData, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
@@ -43,6 +43,7 @@ function detail(c: Context, id: string): Reply {
const h = c.service.current(id), d = snapshot(c, id);
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
]);
@@ -57,14 +58,19 @@ function detailsForm(c: Context, d: ControlData, creating = false): Reply {
}
function schedulePicker(c: Context, d: ControlData): Reply {
check(c, d);
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + 'detail', d))]);
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
check(c, d); c.service.edit(d.habitId!, habitPatch.parse({ schedule })); consume(c); return detail(c, d.habitId!);
check(c, d);
if (d.taskId) c.service.changeTask(d.habitId!, d.taskId, taskPatch.parse({ schedule }));
else c.service.edit(d.habitId!, habitPatch.parse({ schedule }));
consume(c); return d.taskId ? taskDetail(c, d) : detail(c, d.habitId!);
}
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
if (!action.startsWith(prefix)) return;
const a = action.slice(prefix.length);
const taskReply = taskAction(c, a, d);
if (taskReply) return taskReply;
if (a === 'list') return manageHabits(c, d);
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
if (a === 'detail') return detail(c, d.habitId!);
@@ -106,7 +112,8 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
return message(c, 'Select all days on which this repeats.', [menu]);
}
const s = h.schedule;
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
return modal(c, kind === 'weekly' ? 'Repeat every N weeks' : 'Repeat every N days', prefix + 'save-interval', { ...d, mode: kind }, [
{ id: 'every', label: kind === 'weekly' ? 'Number of weeks (1–520)' : 'Number of days (1–3650)', value: 'every' in s ? String(s.every) : '1' },
{ id: 'anchor', label: 'Anchor date (YYYY-MM-DD)', value: 'anchor' in s ? s.anchor : c.service.today, max: 10 },
@@ -120,3 +127,61 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
}
throw new ApiError(422, 'Unknown management action. Run /habits again.');
}
export function manageTasks(c: Context, d: ControlData = {}): Reply {
const habits = c.service.list().filter(h => h.method === 'tasks');
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
...pages(c, prefix + 'tasks-home', { page }, habits.length),
]);
}
function taskList(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!);
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
const state = { ...snapshot(c, h.id), page };
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
...pages(c, prefix + 'tasks-list', state, tasks.length),
]);
}
function taskDetail(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!), task = c.service.tasks(h.id).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found. Run /tasks again.');
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const state = { ...snapshot(c, h.id), taskId: task.id };
const warning = h.warnings.find(w => w.taskId === task.id);
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
]);
}
function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
if (a === 'tasks-home') return manageTasks(c, d);
if (a === 'tasks-open') return taskList(c, { habitId: chosen(c, c.service.list().filter(h => h.method === 'tasks').map(h => h.id)) });
if (a === 'tasks-list') return taskList(c, d);
if (a === 'task-open') { check(c, d); return taskDetail(c, { ...d, taskId: chosen(c, c.service.tasks(d.habitId!).map(t => t.id)) }); }
if (a === 'task-detail') return taskDetail(c, d);
if (a === 'task-add' || a === 'task-rename') {
check(c, d);
const task = a === 'task-rename' ? c.service.tasks(d.habitId!).find(t => t.id === d.taskId) : undefined;
if (a === 'task-rename' && !task) throw new ApiError(404, 'Task not found.');
return modal(c, task ? 'Rename task' : 'Add task (daily recurrence)', prefix + 'task-save', { ...d, taskId: task?.id }, [{ id: 'name', label: 'Task name', value: task?.name }]);
}
if (a === 'task-save') {
check(c, d);
const input = { name: fields(c.interaction).name };
const task = c.service.changeTask(d.habitId!, d.taskId, d.taskId ? taskPatch.parse(input) : taskInput.parse(input))!;
consume(c); return taskDetail(c, { ...d, taskId: task.id });
}
if (a === 'task-delete') {
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found.');
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
}
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
}

View File

@@ -9,7 +9,7 @@ import { ApiError, HabitService } from '../habits/service';
import { ControlStore } from './state';
import { interactionSchema, type Context, type Reply } from './types';
import { dailyAction, today } from './daily';
import { manageHabits, managementAction } from './management';
import { manageHabits, manageTasks, managementAction } from './management';
import { message } from './ui';
import { help } from './help';
@@ -48,6 +48,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
let result: Reply;
try {
if (i.type === 4) result = { type: 8, data: { choices: [] } };
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx);
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
else if ([3, 5].includes(i.type)) {