From 3487488f7bc042d3ff6feb9bedd438df34d6f9cc Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:21:13 +0200 Subject: [PATCH] feat: add Discord habit history and dated progress corrections --- src/discord/commands.ts | 1 + src/discord/history.test.ts | 199 ++++++++++++++++++++++++++++++++++++ src/discord/history.ts | 64 ++++++++++++ src/discord/routes.ts | 4 +- 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/discord/history.test.ts create mode 100644 src/discord/history.ts diff --git a/src/discord/commands.ts b/src/discord/commands.ts index d35e1c3..d4c8c24 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -4,4 +4,5 @@ export const commands = [ { name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' }, { name: 'habits', description: 'Create, edit, archive and restore your habits' }, { name: 'today', description: 'View and complete today’s habits' }, + { name: 'history', description: 'View and correct habit progress for a calendar date' }, ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); diff --git a/src/discord/history.test.ts b/src/discord/history.test.ts new file mode 100644 index 0000000..bdfcaaf --- /dev/null +++ b/src/discord/history.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { control, discordFixture, submit } from './test-fixture'; +import { commands } from './commands'; + +let f: ReturnType; +afterEach(() => f?.close()); +const items = (reply: any): any[] => reply.data.components.flatMap((r: any) => r.components ?? []); +async function click(reply: any, label: string, guild = false, values?: string[], user?: string) { + return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild, user); +} +async function open(habitId: string, date: string, guild = false) { + const list = await f.run({ data: { name: 'history' } }, guild); + const form = await click(list, 'Choose a habit', guild, [habitId]); + expect(form.type).toBe(9); + return f.run(submit(form.data.custom_id, { date }), guild); +} +async function changeDate(reply: any, date: string, guild = false) { + const form = await click(reply, 'Change date', guild); + return f.run(submit(form.data.custom_id, { date }), guild); +} +function readOnly(reply: any) { + expect(reply.data.content).toContain('Read-only'); + expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false); +} + +describe('/history', () => { + test('is global and available in both interaction contexts', () => { + expect(commands.find(c => c.name === 'history')).toMatchObject({ type: 1, contexts: [0, 1], integration_types: [0] }); + }); + + for (const guild of [false, true]) test(`corrects archived count/manual/task history from ${guild ? 'server' : 'DM'} using dated website requirements`, async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 5, unit: 'pages' }, 201); + f.setDiscordTime('2026-09-05T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { target: 20, unit: 'chapters' }); + f.setDiscordTime('2026-09-06T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' }); + f.setDiscordTime('2026-09-07T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { method: 'tasks' }); + const task = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'Original task' }, 201); + f.setDiscordTime('2026-09-08T12:00:00Z'); + await f.json(`/habits/${h.id}/tasks/${task.id}`, 'PATCH', { name: 'New task', schedule: { type: 'weekdays', days: [1] } }); + f.setDiscordTime('2026-09-09T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { archived: true }); + + const list = await f.run({ data: { name: 'history' } }, guild); + expect(list.data.flags).toBe(guild ? 64 : undefined); + expect(items(list)[0].options[0].description).toContain('Archived'); + let d = await open(h.id, '2026-09-04', guild); + expect(d.data.content).toContain('0/5 pages'); + const amount = await click(d, 'Enter amount', guild); + d = await f.run(submit(amount.data.custom_id, { amount: '4' }), guild); + expect(d.data.content).toContain('4/5 pages'); + expect(control(d, 'Change date')).toBeTruthy(); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4); + d = await click(d, 'Next date', guild); + expect(d.data.content).toContain('0/20 chapters'); + d = await click(d, 'Next date', guild); + expect(d.data.content).toContain('Checkbox'); + d = await click(d, 'Mark complete', guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true); + d = await click(d, 'Mark incomplete', guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false); + d = await click(d, 'Next date', guild); + expect(d.data.content).toContain('Original task'); + expect(d.data.content).not.toContain('New task'); + d = await click(d, 'Choose a task to change its completion', guild, [task.id]); + expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true); + d = await click(d, 'Next date', guild); + readOnly(d); + d = await click(d, 'Next date', guild); + readOnly(d); + expect(d.data.content).toContain('archived on this date'); + expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']); + expect((await f.json(`/habits/${h.id}`)).archived).toBe(true); + }); + + test('validates actual calendar dates and navigates read-only days and date boundaries', async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201); + let d = await open(h.id, '2026-02-30'); + expect(d.data.content).toContain('real calendar date'); + for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) { + d = await changeDate(d, date); + expect(d.data.content).toContain('real calendar date'); + } + d = await changeDate(d, '2024-02-29'); + readOnly(d); + expect(d.data.content).toContain('not tracked yet'); + d = await changeDate(d, '2026-09-05'); + expect(d.data.content).toContain('Future dates are read-only'); + expect(items(d).some(i => i.label === 'Mark complete')).toBe(false); + f.setDiscordTime('2026-09-06T12:00:00Z'); + d = await open(h.id, '2026-09-05'); + readOnly(d); + expect(d.data.content).toContain('Friday'); + d = await changeDate(d, '1970-01-01'); + expect(items(d).some(i => i.label === 'Previous date')).toBe(false); + d = await changeDate(d, '9998-12-31'); + expect(items(d).some(i => i.label === 'Next date')).toBe(false); + expect(f.changed).toEqual([]); + }); + + test('paginates habits and dated tasks, preserving history through task pagination and refresh', async () => { + f = discordFixture(); + for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201); + const h = await f.json('/habits', 'POST', { name: 'Checklist', method: 'tasks', tasks: Array.from({ length: 23 }, (_, i) => ({ name: `Task ${i}` })) }, 201); + let list = await f.run({ data: { name: 'history' } }); + expect(items(list)[0].options).toHaveLength(10); + list = await click(list, 'Next'); + expect(items(list)[0].options).toHaveLength(3); + expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2'); + const form = await click(list, 'Choose a habit', false, [h.id]); + let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' })); + d = await click(d, 'Next'); + expect(items(d)[0].options[0].label).toBe('○ Task 10'); + expect(d.data.components.length).toBeLessThanOrEqual(5); + d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]); + expect(d.data.content).toContain('✓ Task 10'); + d = await click(d, 'Refresh'); + expect(d.data.content).toContain('✓ Task 10'); + expect(control(d, 'Change date')).toBeTruthy(); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true); + }); + + for (const guild of [false, true]) test(`rejects cross-account controls and forged selections in ${guild ? 'server' : 'DM'}`, async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201); + await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b'); + const a = await f.run({ data: { name: 'history' } }, guild); + const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob); + expect(rejected.data.content).toContain('belongs to another user'); + expect(rejected.data.flags).toBe(guild ? 64 : undefined); + const b = await f.run({ data: { name: 'history' } }, guild, f.bob); + expect(JSON.stringify(b)).not.toContain('Secret'); + expect((await click(b, 'Choose a habit', guild, [h.id], f.bob)).data.content).toBe('Not found'); + const d = await open(h.id, '2026-09-04', guild); + expect((await click(d, 'Mark complete', guild, undefined, f.bob)).data.content).toContain('belongs to another user'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0); + expect(f.changed).toEqual([]); + }); + + test('keeps stale controls, mutation consumption, replay receipts and website edits consistent', async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201); + f.setDiscordTime('2026-09-05T12:00:00Z'); + let d = await open(h.id, '2026-09-04'); + const oldAmount = await click(d, 'Enter amount'); + await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 }); + expect((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))).data.content).toContain('Progress changed'); + d = await click(d, 'Refresh'); + expect(d.data.content).toContain('8/10'); + const interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } }); + const saved = await (await f.requestDiscord(interaction)).json(); + f.restart(); + expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved); + expect((await click(d, '+1')).data.content).toContain('expired'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(9); + expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2); + expect(f.changed).toEqual(['alice']); + d = await open(h.id, '2026-09-04'); + f.setDiscordTime('2026-09-05T12:16:00Z'); + expect((await click(d, '+1')).data.content).toContain('expired'); + }); + + test('uses account-local dates at midnight and retains historical record timezone', async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201); + f.setDiscordTime('2026-09-04T22:05:00Z'); + const list = await f.run({ data: { name: 'history' } }); + const form = await click(list, 'Choose a habit', false, [h.id]); + expect(form.data.components[0].components[0].value).toBe('2026-09-05'); + await f.json('/me', 'PATCH', { timezone: 'America/New_York' }); + const d = await open(h.id, '2026-09-04'); + expect(d.data.content).toContain('Europe/Belgrade'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).timezone).toBe('Europe/Belgrade'); + }); + + test('keeps earlier schedule requirements editable after a schedule change', async () => { + f = discordFixture(); + const empty = await f.run({ data: { name: 'history' } }); + expect(empty.data.content).toContain('No habits yet'); + expect(items(empty)).toEqual([]); + const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201); + f.setDiscordTime('2026-09-05T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } }); + let d = await open(h.id, '2026-09-04'); + expect(d.data.content).toContain('Every day'); + d = await click(d, 'Mark complete'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true); + d = await click(d, 'Next date'); + readOnly(d); + expect(d.data.content).toContain('Monday'); + d = await changeDate(d, '2026-09-07'); + expect(d.data.content).toContain('Future dates are read-only'); + expect(items(d).some(i => i.label === 'Mark complete')).toBe(false); + expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false); + }); +}); diff --git a/src/discord/history.ts b/src/discord/history.ts new file mode 100644 index 0000000..6348444 --- /dev/null +++ b/src/discord/history.ts @@ -0,0 +1,64 @@ +import { addDays } from '../habits/calendar'; +import { dateSchema, type Schedule } from '../habits/contracts'; +import { dailyAction, detail } from './daily'; +import { fields, type Context, type ControlData, type Message, type Reply } from './types'; +import { button, message, modal, pages, row, select, text } from './ui'; + +const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +function scheduleLabel(schedule: Schedule): string { + if (schedule.type === 'daily') return 'Every day'; + if (schedule.type === 'weekdays') return schedule.days.map(day => weekdays[day]).join(', '); + if (schedule.type === 'interval') return `Every ${schedule.every} days from ${schedule.anchor}`; + return `Every ${schedule.every} weeks on ${weekdays[schedule.weekday]} from ${schedule.anchor}`; +} + +export function history(c: Context, data: ControlData = {}): Reply { + const all = c.service.list(true); + const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)); + const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false }; + const visible = all.slice(page * 10, page * 10 + 10); + return message(c, `**History** · ${text(c.service.user.timezone)}\nChoose a habit, then enter a calendar date. Archived habits are included.${all.length ? `\nPage ${page + 1} of ${Math.ceil(all.length / 10)}` : '\nNo habits yet. Use /habits to create one.'}`, + [...(visible.length ? [select(c, 'Choose a habit', 'history.select', state, visible.map(h => ({ id: h.id, name: h.name, description: `${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}` })))] : []), ...pages(c, 'history', state, all.length)]); +} + +function dateForm(c: Context, data: ControlData): Reply { + c.service.owned(data.habitId!); + return modal(c, 'Choose history date', 'history.date', data, [{ id: 'date', label: 'Calendar date (YYYY-MM-DD)', value: data.date ?? c.service.today, max: 10 }]); +} + +// Keep the dated editor and all mutation checks in daily.ts. Its opaque payloads +// preserve mode/today, so subsequent saves, forms and task pages return here. +function withNavigation(c: Context, data: ControlData, reply: Reply): Reply { + if (![4, 7].includes(reply.type)) return reply; + const d = c.service.day(data.habitId!, data.date!); + const state: ControlData = { habitId: data.habitId, date: data.date, mode: 'history', today: false, page: 0 }; + const navigation = [ + ...(data.date! > '1970-01-01' ? [button(c, 'Previous date', 'history.detail', { ...state, date: addDays(data.date!, -1) })] : []), + ...(data.date! < '9998-12-31' ? [button(c, 'Next date', 'history.detail', { ...state, date: addDays(data.date!, 1) })] : []), + button(c, 'Change date', 'history.form', state), + button(c, 'Choose habit', 'history', { date: data.date }), + ]; + const explanation = !d.requirements ? 'Read-only · This habit was not tracked yet.' + : d.requirements.archived ? 'Read-only · This habit was archived on this date.' + : !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : ''; + const body = reply.data as Message; + const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : ''; + return { ...reply, data: { ...body, content: `${requirements}${text(d.timezone)}\n${explanation ? explanation + '\n' : ''}${body.content ?? ''}`.slice(0, 2000), components: [...(body.components ?? []), row(...navigation)] } }; +} + +export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined { + if (action === 'history') return history(c, data); + if (action === 'history.select') return dateForm(c, { ...data, habitId: c.interaction.data?.values?.[0], page: 0 }); + if (action === 'history.form') return dateForm(c, data); + if (action === 'history.date') { + const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim()); + if (!parsed.success) return message(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]); + data = { ...data, date: parsed.data, page: 0 }; + action = 'history.detail'; + } + if (action === 'history.detail' || (data.mode === 'history' && action.startsWith('daily.'))) { + const state = { ...data, date: dateSchema.parse(data.date), mode: 'history', today: false }; + const reply = action === 'history.detail' ? detail(c, state) : dailyAction(c, action, state); + return reply && withNavigation(c, state, reply); + } +} diff --git a/src/discord/routes.ts b/src/discord/routes.ts index 0b8c837..2808440 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -10,6 +10,7 @@ import { ControlStore } from './state'; import { interactionSchema, type Context, type Reply } from './types'; import { dailyAction, today } from './daily'; import { manageHabits, manageTasks, managementAction } from './management'; +import { history, historyAction } from './history'; import { message } from './ui'; import { help } from './help'; @@ -51,9 +52,10 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx); 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 === 'history') result = history(ctx); else if ([3, 5].includes(i.type)) { const state = controls.read(i.data?.custom_id ?? ''); - result = dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); + result = historyAction(ctx, state.action, state.payload) ?? 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;