feat: manage routine tasks through Discord forms and recurrence controls
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// Global commands are required for bot DMs; guild-only registrations cannot run there.
|
// Global commands are required for bot DMs; guild-only registrations cannot run there.
|
||||||
export const commands = [
|
export const commands = [
|
||||||
{ name: 'help', description: 'Show commands and how to use Minabot' },
|
{ 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: 'habits', description: 'Create, edit, archive and restore your habits' },
|
||||||
{ name: 'today', description: 'View and complete today’s habits' },
|
{ name: 'today', description: 'View and complete today’s habits' },
|
||||||
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));
|
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));
|
||||||
|
|||||||
123
src/discord/management-tasks.test.ts
Normal file
123
src/discord/management-tasks.test.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -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 { ApiError } from '../habits/service';
|
||||||
import { fields, type Context, type ControlData, type Reply } from './types';
|
import { fields, type Context, type ControlData, type Reply } from './types';
|
||||||
import { button, message, modal, pages, row, select, text } from './ui';
|
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);
|
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.`, [
|
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)),
|
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))] : []),
|
...(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')),
|
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 {
|
function schedulePicker(c: Context, d: ControlData): Reply {
|
||||||
check(c, d);
|
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 {
|
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 {
|
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
|
||||||
if (!action.startsWith(prefix)) return;
|
if (!action.startsWith(prefix)) return;
|
||||||
const a = action.slice(prefix.length);
|
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 === '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 === '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!);
|
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 });
|
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]);
|
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 }, [
|
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: '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 },
|
{ 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.');
|
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 }); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ApiError, HabitService } from '../habits/service';
|
|||||||
import { ControlStore } from './state';
|
import { ControlStore } from './state';
|
||||||
import { interactionSchema, type Context, type Reply } from './types';
|
import { interactionSchema, type Context, type Reply } from './types';
|
||||||
import { dailyAction, today } from './daily';
|
import { dailyAction, today } from './daily';
|
||||||
import { manageHabits, managementAction } from './management';
|
import { manageHabits, manageTasks, managementAction } from './management';
|
||||||
import { message } from './ui';
|
import { message } from './ui';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
|||||||
let result: Reply;
|
let result: Reply;
|
||||||
try {
|
try {
|
||||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
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 === 'habits') result = manageHabits(ctx);
|
||||||
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
|
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
|
||||||
else if ([3, 5].includes(i.type)) {
|
else if ([3, 5].includes(i.type)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user