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

@@ -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');
});