feat: manage habits through private Discord controls
This commit is contained in:
122
src/discord/management.ts
Normal file
122
src/discord/management.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { habitInput, habitPatch, 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 === '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 + '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!);
|
||||
}
|
||||
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
|
||||
if (!action.startsWith(prefix)) return;
|
||||
const a = action.slice(prefix.length);
|
||||
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 = h.schedule;
|
||||
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.');
|
||||
}
|
||||
Reference in New Issue
Block a user