From 07a0f80fc0d4f9bd33122e942155c2c8ff512964 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:20:32 +0200 Subject: [PATCH] feat: manage habits through private Discord controls --- src/discord/commands.ts | 1 + src/discord/management.test.ts | 98 ++++++++++++++++++++++++++ src/discord/management.ts | 122 +++++++++++++++++++++++++++++++++ src/discord/routes.ts | 4 +- 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/discord/management.test.ts create mode 100644 src/discord/management.ts diff --git a/src/discord/commands.ts b/src/discord/commands.ts index ea41fb1..ce80fc5 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -1,5 +1,6 @@ // Global commands are required for bot DMs; guild-only registrations cannot run there. export const commands = [ { 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' }, ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); diff --git a/src/discord/management.test.ts b/src/discord/management.test.ts new file mode 100644 index 0000000..c3a8e39 --- /dev/null +++ b/src/discord/management.test.ts @@ -0,0 +1,98 @@ +import { afterEach, expect, test } from 'bun:test'; +import { discordFixture, control, submit } from './test-fixture'; +let f: ReturnType; +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(); +}); diff --git a/src/discord/management.ts b/src/discord/management.ts new file mode 100644 index 0000000..7a98628 --- /dev/null +++ b/src/discord/management.ts @@ -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[])[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.'); +} diff --git a/src/discord/routes.ts b/src/discord/routes.ts index c3ef251..7a359b5 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -9,6 +9,7 @@ import { ApiError, HabitService } from '../habits/service'; import { ControlStore } from './state'; import { interactionSchema, type Context, type Reply } from './types'; import { dailyAction, today } from './daily'; +import { manageHabits, managementAction } from './management'; import { message } from './ui'; import { help } from './help'; @@ -47,10 +48,11 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now let result: Reply; try { 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 ([3, 5].includes(i.type)) { 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); } catch (error) { if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;