feat: add Discord habit history and dated progress corrections

This commit is contained in:
syntaxbullet
2026-09-08 08:21:13 +02:00
parent ab2aa74eba
commit 3487488f7b
4 changed files with 267 additions and 1 deletions

View File

@@ -4,4 +4,5 @@ export const commands = [
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' }, { name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
{ name: 'habits', description: 'Create, edit, archive and restore your habits' }, { 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' },
{ name: 'history', description: 'View and correct habit progress for a calendar date' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

199
src/discord/history.test.ts Normal file
View File

@@ -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<typeof discordFixture>;
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);
});
});

64
src/discord/history.ts Normal file
View File

@@ -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);
}
}

View File

@@ -10,6 +10,7 @@ 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, manageTasks, managementAction } from './management'; import { manageHabits, manageTasks, managementAction } from './management';
import { history, historyAction } from './history';
import { message } from './ui'; import { message } from './ui';
import { help } from './help'; 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 === 'tasks') result = manageTasks(ctx);
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(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 === 'today') result = today(ctx);
else if (i.type === 2 && i.data?.name === 'history') result = history(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) ?? 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); } 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;