Files
minabot/src/discord/management.ts

188 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
const prefix = 'manage:';
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const recurrence = [{ id: 'daily', name: 'Every day' }, { id: 'weekdays', name: 'Selected weekdays' }, { id: 'interval', name: 'Every N days' }, { id: 'weekly', name: 'Every N weeks' }];
function stamp(c: Context, id: string) {
const h = c.service.current(id), day = c.service.day(id, c.service.today);
return JSON.stringify([h.revisionId, c.service.settings(id), c.service.today, c.service.user.timezone, day.value, day.tasks.map(t => [t.taskId, t.done])]);
}
function snapshot(c: Context, id: string): ControlData { return { habitId: id, expected: stamp(c, id) }; }
function check(c: Context, d: ControlData) {
if (!d.habitId || d.expected !== stamp(c, d.habitId)) throw new ApiError(409, 'This habit changed. Run /habits or /tasks again before saving.');
return c.service.current(d.habitId);
}
function consume(c: Context) { c.controls.consume(c.interaction.data!.custom_id!); c.changed(); }
function chosen(c: Context, allowed: string[]) {
const value = c.interaction.data?.values?.[0];
if (!value || !allowed.includes(value)) throw new ApiError(422, 'Choose one of the available options.');
return value;
}
export function scheduleLabel(s: Schedule): string {
if (s.type === 'daily') return 'Every day';
if (s.type === 'weekdays') return s.days.map(d => weekdays[d]).join(', ');
if (s.type === 'interval') return `Every ${s.every} days from ${s.anchor}`;
return `Every ${s.every} weeks on ${weekdays[s.weekday]} from ${s.anchor}`;
}
export function manageHabits(c: Context, d: ControlData = {}): Reply {
const archived = d.mode === 'archived';
const habits = c.service.list(true).filter(h => h.archived === archived);
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
const state = { mode: archived ? 'archived' : 'active', page };
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
...pages(c, prefix + 'list', state, habits.length),
]);
}
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')),
]);
}
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
return modal(c, creating ? 'Create habit' : 'Edit habit', prefix + (creating ? 'save-create' : 'save-details'), { ...d, method }, [
{ id: 'name', label: 'Habit name', value: h?.name },
...(method === 'count' ? [{ id: 'target', label: 'Target (1–10000)', value: h?.method === 'count' ? String(h.target) : '1' }, { id: 'unit', label: 'Unit (pages, steps, minutes…)', max: 80, value: h?.method === 'count' ? h.unit : 'times' }] : []),
{ id: 'color', label: 'Color (#RRGGBB)', value: h ? c.service.settings(h.id).mainColor : '#196127', max: 7 },
]);
}
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 + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
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!);
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
if (a === 'save-create') {
const f = fields(c.interaction);
const h = c.service.create(habitInput.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, h.id);
}
if (a === 'edit') return detailsForm(c, d);
if (a === 'save-details') {
check(c, d); const f = fields(c.interaction);
c.service.edit(d.habitId!, habitPatch.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, d.habitId!);
}
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
if (a === 'method-confirm') {
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'convert') {
check(c, d);
if (d.method === 'count') return detailsForm(c, d);
c.service.edit(d.habitId!, habitPatch.parse({ method: d.method })); consume(c); return detail(c, d.habitId!);
}
if (a === 'confirm') {
const h = check(c, d);
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
if (a === 'schedule') return schedulePicker(c, d);
if (a === 'schedule-kind') {
const h = check(c, d), kind = chosen(c, recurrence.map(r => r.id));
if (kind === 'daily') return saveSchedule(c, d, { type: 'daily' });
if (kind === 'weekdays') {
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
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 = 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 },
...(kind === 'weekly' ? [{ id: 'weekday', label: 'Weekday (Monday, Tuesday…)', value: s.type === 'weekly' ? weekdays[s.weekday] : 'Monday' }] : []),
]);
}
if (a === 'save-weekdays') return saveSchedule(c, d, scheduleSchema.parse({ type: 'weekdays', days: (c.interaction.data?.values ?? []).map(Number) }));
if (a === 'save-interval') {
const f = fields(c.interaction), weekday = weekdays.findIndex(day => day.toLowerCase() === f.weekday?.trim().toLowerCase());
return saveSchedule(c, d, scheduleSchema.parse({ type: d.mode, every: Number(f.every), anchor: f.anchor, ...(d.mode === 'weekly' ? { weekday } : {}) }));
}
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 }); }
}