Build interactive Discord today dashboard and check-ins

This commit is contained in:
syntaxbullet
2026-10-26 19:56:12 +01:00
parent 7be401d105
commit 1a97036112
9 changed files with 183 additions and 48 deletions

View File

@@ -129,3 +129,24 @@ needed after deploying this change.
Generate local visual review artifacts with
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.
## Interactive daily dashboard
`/today` uses Discord Components V2 containers with existing habit accent colors,
a daily completion summary, and unfinished habits first. Five habits fit on each
page within Discord's 40-component limit. Manual habits have inline completion
and undo actions; counts have inline +1; routines open a checklist with individual
Complete/Undo buttons. Details retain exact amounts, carryover, and amount forms.
Checklists paginate eight tasks at a time.
Navigation, saves, and recoverable daily validation errors update the originating
message (including amount-modal submissions). Updates clear legacy content,
embeds, and attachments when upgrading an older message to V2. Server command
replies remain ephemeral; reminder buttons open a separate private flow. Optional
PNG cards still open as separate snapshots. Historical editors keep their dated
requirements and existing interface.
The shared HabitService, owner-bound controls, stale snapshot checks, replay
receipts, and local-midnight protections remain authoritative. No schema change,
new API, or command registration is required. Native Discord mobile/desktop layout
and real modal return behavior should be checked manually after deployment.

View File

@@ -8,7 +8,7 @@ import type { Context } from './types';
import type { DeferredMessage } from './progress';
import { commandCard } from './command-graphics';
import { renderCommandCard, commandCardSize } from './command-image';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, messageText, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
function setup() {
@@ -29,17 +29,17 @@ for (const guild of [false, true]) test(`day graphics retain private context and
const payload = f.payload(click(control(t, 'View day card')), guild);
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete');
let card = await e.drain(); expect(messageText(card)).toContain('0 of 1 habits complete');
expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
expect(f.db.select().from(progressEvents).all()).toEqual([]);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1');
expect(messageText(card)).toContain('12/10 pages'); expect(messageText(card)).toContain('1 of 1');
t = await f.run({ data: { name: 'today' } }, guild);
const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild);
const d = await f.run(click(control(t, 'Details'), [h.id]), guild);
await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages');
expect(messageText(card)).toContain('12/10 pages');
expect(f.changed).toHaveLength(0);
});
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
@@ -53,7 +53,7 @@ test('graphics retain ownership, expiry, pagination and selected historical requ
const id = control(day, 'View check-in card');
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
await f.run(click(id), true); const card = await e.drain();
expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4');
expect(messageText(card)).toContain('Old target'); expect(messageText(card)).toContain('3/4');
f.setDiscordTime('2026-09-06T12:00:00Z');
expect((await f.run(click(id), true)).data.content).toContain('expired');
});
@@ -63,14 +63,14 @@ test('six-row cards paginate complete habit and reminder collections', async ()
const lastName = (await f.json('/habits')).habits.at(-1).name;
const today = await f.run({ data: { name: 'today' } });
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName);
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName);
expect(messageText(card)).toContain('Page 1/2'); expect(messageText(card)).not.toContain(lastName);
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain(lastName);
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1,
reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run();
const panel = await f.run({ data: { name: 'reminders' } });
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri');
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7');
expect(messageText(card)).toContain('During quiet hours'); expect(messageText(card)).toContain('Mon, Wed, Fri');
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain('Reminder 7');
});
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
setup();

View File

@@ -1,17 +1,23 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, components, messageText, habitControl, control, submit } from './test-fixture';
import { discordControls, reminderSettings } from '../db/schema';
import { defaultReminder } from '../reminders/contracts';
import { sendDueReminders } from '../reminders/worker';
import { reminderCheckIn } from './daily';
import { ControlStore } from './state';
function habitTaskControl(reply: any, name: string): string {
const panel = reply.data.components[0];
const section = panel.components.find((item: any) => item.type === 9 && item.components[0].content.endsWith(name));
return section.accessory.custom_id;
}
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
async function open(habitId: string, guild = false) {
const t = await f.run({ data: { name: 'today' } }, guild);
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
const h = await f.json(`/habits/${habitId}`);
return f.run(click(habitControl(t, h.name)), guild);
}
for (const guild of [false, true]) {
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
@@ -19,18 +25,18 @@ for (const guild of [false, true]) {
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
let d = await open(h.id, guild); expect(messageText(d)).toContain('Carried from 2026-09-04');
d = await f.run(click(control(d, '+1')), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
let m = await f.run(click(control(d, 'Enter amount')), guild);
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
expect(messageText(invalid)).toContain('whole number'); expect(invalid.data.flags).toBe(32768);
m = await f.run(click(control(invalid, 'Try again')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
expect(d.data.content).toContain('12/10');
expect(messageText(d)).toContain('12/10');
m = await f.run(click(control(d, 'Enter amount')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
expect(d.data.content).toContain('Incomplete');
expect(messageText(d)).toContain('Not started');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
expect(f.changed).toHaveLength(3);
@@ -46,22 +52,22 @@ for (const guild of [false, true]) {
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
d = await open(h.id, guild);
d = await f.run(click(control(d, 'Next')), guild);
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
expect(messageText(d)).toContain('Task 11'); expect(messageText(d)).not.toContain('Task 8');
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
// Website task changes invalidate an already-open Discord selection.
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(stale.data.content).toContain('Progress changed');
const stale = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect(messageText(stale)).toContain('Progress changed');
d = await f.run(click(control(stale, 'Refresh')), guild);
expect(d.data.content).toContain('11/11 tasks · Complete');
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(messageText(d)).toContain('11/11 tasks');
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await open(manual.id, guild)).data.content).toContain('Complete');
expect(messageText(await open(manual.id, guild))).toContain('Complete');
});
}
test('habit pagination clamps after website archives', async () => {
@@ -71,7 +77,7 @@ test('habit pagination clamps after website archives', async () => {
const next = control(t, 'Next');
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const refreshed = await f.run(click(next));
expect(refreshed.data.content).toContain('Habit 0');
expect(messageText(refreshed)).toContain('Habit 0');
});
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
@@ -79,9 +85,9 @@ test('daily controls reject midnight rollover even within their fifteen minute l
const d = await open(h.id);
f.setDiscordTime('2026-09-04T22:00:00Z');
const expired = await f.run(click(control(d, 'Mark complete')));
expect(expired.data.content).toContain('day changed');
expect(messageText(expired)).toContain('day changed');
const fresh = await f.run(click(control(expired, 'Today')));
expect(fresh.data.content).toContain('2026-09-05');
expect(messageText(fresh)).toContain('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
@@ -98,8 +104,8 @@ test('reminder buttons are owner bound, open a new flow, expire at local midnigh
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
for (const guild of [false, true]) {
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
expect(flow.data.content).toContain('Walk');
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(messageText(flow)).toContain('Walk');
}
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
});
@@ -110,3 +116,54 @@ test('reminder expiry follows DST boundary', () => {
const id = control({ data: { components } }, 'Check in');
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
});
for (const guild of [false, true]) test(`inline dashboard actions update the same message and finish the day (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const walk = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 1 }, 201);
let t = await f.run({ data: { name: 'today' } }, guild);
expect(t.data.content).toBeUndefined();
expect(t.data.flags).toBe(32768 | (guild ? 64 : 0));
const old = habitControl(t, 'Water', '+1');
t = await f.run(click(habitControl(t, 'Walk', 'Mark complete')), guild);
expect(t.type).toBe(7);
expect(t.data.content).toBeNull();
expect(messageText(t)).toContain('1/2 complete');
expect(messageText(t).indexOf('**Water**')).toBeLessThan(messageText(t).indexOf('**Walk**'));
t = await f.run(click(habitControl(t, 'Water', '+1')), guild);
expect(t.type).toBe(7);
expect(messageText(t)).toContain('All done for today');
expect((await f.json(`/habits/${water.id}/days/2026-09-04`)).value).toBe(1);
const stale = await f.run(click(old), guild);
expect(stale.type).toBe(7);
expect(messageText(stale)).toContain('Progress changed');
t = await f.run(click(habitControl(t, 'Walk', 'Mark incomplete')), guild);
expect(messageText(t)).not.toContain('All done for today');
expect((await f.json(`/habits/${walk.id}/days/2026-09-04`)).complete).toBe(false);
});
test('native layouts stay within Discord limits and use existing habit colors', async () => {
f = discordFixture();
for (let n = 0; n < 12; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
let t = await f.run({ data: { name: 'today' } });
for (let page = 0; page < 3; page++) {
expect(components(t.data.components).length).toBeLessThanOrEqual(40);
expect(t.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x196127)).toBe(true);
expect(messageText(t)).toContain(`Page ${page + 1} of 3`);
if (page < 2) t = await f.run(click(control(t, 'Next')));
}
await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, n) => ({ name: `Step ${n}` })) }, 201);
const d = await f.run(click(control(t, 'Details')));
expect(components(d.data.components).length).toBeLessThanOrEqual(40);
const list = await f.run({ data: { name: 'today' } });
const last = await f.run(click(control(await f.run(click(control(list, 'Next'))), 'Next')));
const checklist = await f.run(click(habitControl(last, 'Routine', 'Open checklist')));
expect(components(checklist.data.components).length).toBeLessThanOrEqual(40);
expect(messageText(checklist)).toContain('Page 1 of 3');
});
test('empty dashboard has no mutation controls', async () => {
f = discordFixture();
const t = await f.run({ data: { name: 'today' } });
expect(messageText(t)).toContain('Nothing scheduled today');
expect(messageText(t)).not.toContain('All done');
expect(components(t.data.components).filter(c => c.type === 2).map(c => c.label)).toEqual(['Refresh', 'View day card']);
});

View File

@@ -6,23 +6,59 @@ import type { AppDatabase } from '../auth';
import { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
import { button, message, modal, pages, row, select, text, componentMessage, display, panel, section } from './ui';
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
const date = localDate(now, timezone);
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
}
const dashboardSize = 5;
const taskSize = 8;
const clampPage = (page: number | undefined, total: number, size: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / size) - 1));
const status = (d: ReturnType<Context['service']['day']>) => d.complete ? '✓ Complete' : d.value > 0 ? '◐ In progress' : '○ Not started';
const meter = (ratio: number) => '▰'.repeat(Math.round(Math.min(1, Math.max(0, ratio)) * 8)) + '▱'.repeat(8 - Math.round(Math.min(1, Math.max(0, ratio)) * 8));
export function today(c: Context, data: ControlData = {}): Reply {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'), button(c, 'View day card', 'graphic.today', { date: c.service.today }))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due).sort((a, b) => Number(a.complete) - Number(b.complete));
const page = clampPage(data.page, all.length, dashboardSize), complete = all.filter(d => d.complete).length;
const components = [panel([display(`## ${all.length && complete === all.length ? '✓ All done for today' : 'Today'}\n${c.service.today} · ${text(c.service.user.timezone)}\n${all.length ? `${meter(complete / all.length)} **${complete}/${all.length} complete**` : 'Nothing scheduled today. Enjoy your day off.'}`)])];
for (const d of all.slice(page * dashboardSize, (page + 1) * dashboardSize)) {
const state: ControlData = { habitId: d.habitId, date: c.service.today, today: true, dashboardPage: page, expected: snapshot(d), mode: 'dashboard' };
const primary = d.method === 'manual'
? button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)
: d.method === 'count' && d.value < 1_000_000_000
? button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)
: button(c, d.method === 'tasks' ? 'Open checklist' : 'Edit amount', 'daily.detail', { ...state, mode: undefined }, 1);
components.push(panel([
section(`**${text(d.name ?? 'Habit')}**\n${status(d)} · ${d.value}/${d.target} ${text(d.unit ?? '')}`, primary),
row(button(c, 'Details', 'daily.detail', { ...state, mode: undefined })),
], c.service.settings(d.habitId).mainColor));
}
components.push(display(all.length ? `Page ${page + 1} of ${Math.max(1, Math.ceil(all.length / dashboardSize))} · Unfinished habits first` : 'Use /habits to create or manage your habits.'));
components.push(...pages(c, 'today', { date: c.service.today, page }, all.length, dashboardSize), row(button(c, 'Refresh', 'today', { page }), button(c, 'View day card', 'graphic.today', { date: c.service.today })));
return componentMessage(c, components, data.mode !== 'reminder');
}
function todayDetail(c: Context, data: ControlData): Reply {
const d = c.service.day(data.habitId!, data.date!);
const page = clampPage(data.page, d.tasks.length, taskSize);
const state = { ...data, mode: undefined, page, expected: snapshot(d) };
const components = [display(`## ${text(d.name ?? 'Habit')}\n${data.date} · ${text(d.timezone)}\n${d.due ? `${meter(d.ratio ?? 0)} **${d.value}/${d.target} ${text(d.unit ?? '')}** · ${status(d)}` : 'Not scheduled today'}${d.carriedFrom ? `\nCarried from ${d.carriedFrom}` : ''}`)];
if (d.due && !d.future) {
if (d.method === 'manual') components.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)));
if (d.method === 'count') components.push(row(...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), ...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'tasks') {
for (const task of d.tasks.slice(page * taskSize, (page + 1) * taskSize)) components.push(section(`${task.done ? '✓' : '○'} ${text(task.name)}`, button(c, task.done ? 'Undo' : 'Complete', 'daily.task', { ...state, taskId: task.taskId }, task.done ? 2 : 3)));
if (d.tasks.length > taskSize) components.push(display(`Page ${page + 1} of ${Math.ceil(d.tasks.length / taskSize)}`));
components.push(...pages(c, 'daily.detail', state, d.tasks.length, taskSize));
}
}
return componentMessage(c, [panel(components, c.service.settings(d.habitId).mainColor), row(button(c, 'Today', 'today', { page: data.dashboardPage }), button(c, 'Refresh', 'daily.detail', state), button(c, 'View check-in card', 'graphic.day', { ...state, page: 0 }))]);
}
export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
if (data.today) return todayDetail(c, { ...data, date });
const d = c.service.day(data.habitId!, date);
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
const state: ControlData = { ...data, expected: snapshot(d) };
@@ -50,14 +86,14 @@ function action(c: Context, action: string, data: ControlData): Reply | undefine
const d = c.service.day(data.habitId!, data.date!);
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
const task = d.tasks.find(t => t.taskId === (data.taskId ?? c.interaction.data?.values?.[0]));
if (!task) throw new ApiError(404, 'Task not found.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return data.mode === 'dashboard' ? today(c, { page: data.dashboardPage }) : detail(c, data);
}
}
@@ -67,6 +103,7 @@ export function dailyAction(c: Context, name: string, data: ControlData): Reply
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
const invalid = error instanceof ZodError;
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
if (data.today) return componentMessage(c, [panel([display(invalid ? 'Enter a whole number from 0 to 1,000,000,000. Nothing was saved.' : error.message)]), row(recover, button(c, 'Today', 'today'))]);
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
[row(recover, button(c, 'Today', 'today'))], false);
}

View File

@@ -10,7 +10,7 @@ export function help(guild: boolean, origin: string): Reply {
'',
...commands.map(command => `**/${command.name}** — ${command.description}`),
'',
'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.',
'Start with /today and use the buttons beside each habit. Open Details to adjust an amount or undo a check-in; Open checklist lets you complete individual tasks.',
'If a control expires or progress changes elsewhere, run the command again to get fresh controls.',
'',
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, messageText, control, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
@@ -17,8 +17,8 @@ describe('signed Discord interactions', () => {
for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201);
const today = await f.run({ data: { name: 'today' } }, guild);
expect(today.data.flags).toBe(guild ? 64 : undefined);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
expect(today.data.flags).toBe(32768 | (guild ? 64 : 0));
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Details'), values: [h.id] } }, guild);
const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild);
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
f.restart(); await f.requestDiscord(click);
@@ -26,20 +26,20 @@ describe('signed Discord interactions', () => {
expect(f.changed).toEqual(['alice']);
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild);
expect(failed.data.content).toContain('Progress changed');
expect(messageText(failed)).toContain('Progress changed');
});
test('owner-bound buttons and habit IDs prevent cross-account access', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
const a = await f.run({ data: { name: 'today' } });
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob);
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Details'), values: [h.id] } }, true, f.bob);
expect(messageText(blocked)).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const b = await f.run({ data: { name: 'today' } }, false, f.bob);
expect(b.data.content).not.toContain('Private');
expect(messageText(b)).not.toContain('Private');
});
test('old daily buttons cannot correct history accidentally', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const t = await f.run({ data: { name: 'today' } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Details'), values: [h.id] } });
f.setDiscordTime('2026-09-05T12:00:00Z');
expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);

View File

@@ -28,10 +28,21 @@ export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
const item = components(reply.data.components).find((c: any) => c.label === label || c.placeholder === label);
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
return item.custom_id;
}
export function submit(custom_id: string, values: Record<string, string>) {
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
}
export function components(items: any[] = []): any[] {
return items.flatMap(item => [item, ...components(item.components), ...(item.accessory ? components([item.accessory]) : [])]);
}
export function messageText(reply: any): string {
return [reply.data.content, ...components(reply.data.components).map(item => item.content)].filter(Boolean).join('\n');
}
export function habitControl(reply: any, name: string, label = 'Details'): string {
const item = reply.data.components.find((item: any) => item.type === 17 && messageText({ data: { components: [item] } }).includes(`**${name}**`));
return control({ data: { components: [item] } }, label);
}

View File

@@ -12,9 +12,9 @@ type ComponentInput = { type: number; custom_id?: string; value?: string; values
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Message = { content?: string | null; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
export type ControlData = { dashboardPage?: number; habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void; progress?: ProgressOperations; defer?: (job: () => Promise<DeferredMessage>) => void };
export function fields(i: Interaction) {
const result: Record<string, string> = {};

View File

@@ -19,3 +19,12 @@ export function pages(c: Context, action: string, data: ControlData, total: numb
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
return buttons.length ? [row(...buttons)] : [];
}
/** Native Discord layouts; colors come from the existing habit settings. */
export const display = (content: string): DiscordComponent => ({ type: 10, content });
export const section = (content: string, accessory: DiscordComponent): DiscordComponent => ({ type: 9, components: [display(content)], accessory });
export const panel = (components: DiscordComponent[], color = '#285d49'): DiscordComponent => ({ type: 17, accent_color: parseInt(color.replace('#', ''), 16), components });
export function componentMessage(c: Context, components: DiscordComponent[], update = true): Reply {
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
return { type: edit ? 7 : 4, data: { components, flags: 32768 | (!edit && c.interaction.guild_id ? 64 : 0), allowed_mentions: { parse: [] }, ...(edit ? { content: null, embeds: [], attachments: [] } : {}) } };
}