feat: manage habits through private Discord controls

This commit is contained in:
syntaxbullet
2026-09-08 08:20:32 +02:00
parent 8b9bd75f24
commit 07a0f80fc0
4 changed files with 224 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
// 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: '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] }));

View File

@@ -0,0 +1,98 @@
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 open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
async function create(method: string, guild = false) {
const list = await f.run({ data: { name: 'habits' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
const methodMenu = await click(list, 'Create habit', guild);
const form = await click(methodMenu, 'Tracking method', guild, [method]);
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
expect(result.data.content).toContain('Read');
return { result, habit: (await f.json('/habits')).habits[0] };
}
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture(); const { result, habit } = await create('count', guild);
expect(habit.method).toBe('count'); expect(habit.target).toBe(10);
let detail = await click(result, 'Enable carryover', guild);
expect((await f.json('/habits')).habits[0].carryPartialProgress).toBe(true);
const edit = await click(detail, 'Edit details', guild);
detail = await f.run(submit(edit.data.custom_id, { name: 'Books', target: '20', unit: 'pages', color: '#654321' }), guild);
expect((await f.json('/habits')).habits[0].name).toBe('Books');
const confirmation = await click(detail, 'Archive habit', guild);
detail = await click(confirmation, 'Confirm', guild);
expect((await f.json('/habits')).habits).toEqual([]);
detail = await click(await click(detail, 'Restore habit', guild), 'Confirm', guild);
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
expect(detail.data.content).toContain('No habits');
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
expect(f.changed.length).toBe(7);
});
test('all recurrences use validated friendly controls', async () => {
f = discordFixture(); const { habit } = await create('manual');
for (const [kind, values, expected] of [
['interval', { every: '3', anchor: '2026-09-01' }, { type: 'interval', every: 3, anchor: '2026-09-01' }],
['weekly', { every: '2', anchor: '2026-09-01', weekday: 'Friday' }, { type: 'weekly', every: 2, anchor: '2026-09-01', weekday: 5 }],
] as const) {
const menu = await click(await open(habit.id), 'Change schedule');
const form = await click(menu, 'Recurrence', false, [kind]);
await f.run(submit(form.data.custom_id, { ...values }));
expect((await f.json('/habits')).habits[0].schedule).toEqual(expected);
}
let menu = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['weekdays']);
await click(menu, 'Select weekdays', false, ['1', '3', '5']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'weekdays', days: [1, 3, 5] });
menu = await click(await open(habit.id), 'Change schedule');
await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
});
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
f = discordFixture(); const { habit } = await create('manual');
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
f.setDiscordTime('2026-09-05T12:00:00Z');
const menu = await click(await open(habit.id), 'Change method');
const confirmation = await click(menu, 'New tracking method', false, ['count']);
expect(confirmation.data.content).toContain('resets today');
const form = await click(confirmation, 'Continue conversion');
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
expect(past.method).toBe('manual'); expect(past.value).toBe(1);
const today = await f.json(`/habits/${habit.id}/days/2026-09-05`);
expect(today.method).toBe('count'); expect(today.value).toBe(0);
const next = await click(await click(await open(habit.id), 'Change method'), 'New tracking method', false, ['tasks']);
await click(next, 'Continue conversion');
expect((await f.json('/habits')).habits[0].tasks).toEqual([]);
});
test('stale, replayed, and cross-account management controls cannot write', async () => {
f = discordFixture(); const { result, habit } = await create('manual');
const form = await click(result, 'Edit details');
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
const fresh = await click(await open(habit.id), 'Edit details');
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
await f.requestDiscord(payload); const changes = f.changed.length;
f.restart(); await f.requestDiscord(payload);
expect(f.changed.length).toBe(changes);
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
expect((await f.json('/habits')).habits[0].name).toBe('Final');
});
test('pagination keeps Discord component and option limits', async () => {
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const page = await f.run({ data: { name: 'habits' } });
expect(page.data.components[0].components[0].options.length).toBe(10);
const next = await click(page, 'Next');
expect(next.data.components[0].components[0].options.length).toBe(2);
expect(control(next, 'Previous')).toBeTruthy();
});

122
src/discord/management.ts Normal file
View 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.');
}

View File

@@ -9,6 +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 { message } from './ui'; import { message } from './ui';
import { help } from './help'; import { help } from './help';
@@ -47,10 +48,11 @@ 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 === '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)) {
const state = controls.read(i.data?.custom_id ?? ''); const state = controls.read(i.data?.custom_id ?? '');
result = dailyAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); result = dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
} else result = message(ctx, 'Run /help to see available commands.', [], false); } else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;