188 lines
15 KiB
TypeScript
188 lines
15 KiB
TypeScript
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 }); }
|
||
}
|