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,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 }); }
}