Build interactive Discord today dashboard and check-ins
This commit is contained in:
@@ -129,3 +129,24 @@ needed after deploying this change.
|
|||||||
|
|
||||||
Generate local visual review artifacts with
|
Generate local visual review artifacts with
|
||||||
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.
|
`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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Context } from './types';
|
|||||||
import type { DeferredMessage } from './progress';
|
import type { DeferredMessage } from './progress';
|
||||||
import { commandCard } from './command-graphics';
|
import { commandCard } from './command-graphics';
|
||||||
import { renderCommandCard, commandCardSize } from './command-image';
|
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>;
|
let f: ReturnType<typeof discordFixture>;
|
||||||
afterEach(() => f?.close());
|
afterEach(() => f?.close());
|
||||||
function setup() {
|
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);
|
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 } : {} });
|
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
|
||||||
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
|
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]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
|
||||||
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
|
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
|
||||||
expect(f.db.select().from(progressEvents).all()).toEqual([]);
|
expect(f.db.select().from(progressEvents).all()).toEqual([]);
|
||||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
|
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();
|
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);
|
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();
|
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);
|
expect(f.changed).toHaveLength(0);
|
||||||
});
|
});
|
||||||
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
|
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');
|
const id = control(day, 'View check-in card');
|
||||||
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
|
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();
|
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');
|
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||||
expect((await f.run(click(id), true)).data.content).toContain('expired');
|
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 lastName = (await f.json('/habits')).habits.at(-1).name;
|
||||||
const today = await f.run({ data: { name: 'today' } });
|
const today = await f.run({ data: { name: 'today' } });
|
||||||
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
|
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);
|
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(card.data.content).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,
|
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();
|
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' } });
|
const panel = await f.run({ data: { name: 'reminders' } });
|
||||||
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
|
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');
|
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(card.data.content).toContain('Reminder 7');
|
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 () => {
|
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { afterEach, expect, test } from 'bun:test';
|
import { afterEach, expect, test } from 'bun:test';
|
||||||
import { eq } from 'drizzle-orm';
|
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 { discordControls, reminderSettings } from '../db/schema';
|
||||||
import { defaultReminder } from '../reminders/contracts';
|
import { defaultReminder } from '../reminders/contracts';
|
||||||
import { sendDueReminders } from '../reminders/worker';
|
import { sendDueReminders } from '../reminders/worker';
|
||||||
import { reminderCheckIn } from './daily';
|
import { reminderCheckIn } from './daily';
|
||||||
import { ControlStore } from './state';
|
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>;
|
let f: ReturnType<typeof discordFixture>;
|
||||||
afterEach(() => f?.close());
|
afterEach(() => f?.close());
|
||||||
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
|
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
|
||||||
async function open(habitId: string, guild = false) {
|
async function open(habitId: string, guild = false) {
|
||||||
const t = await f.run({ data: { name: 'today' } }, guild);
|
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]) {
|
for (const guild of [false, true]) {
|
||||||
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
|
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);
|
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 });
|
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
|
||||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
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);
|
d = await f.run(click(control(d, '+1')), guild);
|
||||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
|
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);
|
let m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||||
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), 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);
|
m = await f.run(click(control(invalid, 'Try again')), guild);
|
||||||
d = await f.run(submit(m.data.custom_id, { amount: '12' }), 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);
|
m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||||
d = await f.run(submit(m.data.custom_id, { amount: '0' }), 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-05`)).value).toBe(0);
|
||||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
|
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
|
||||||
expect(f.changed).toHaveLength(3);
|
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);
|
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 open(h.id, guild);
|
||||||
d = await f.run(click(control(d, 'Next')), 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`);
|
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);
|
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);
|
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||||
// Website task changes invalidate an already-open Discord selection.
|
// 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 });
|
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);
|
const stale = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
|
||||||
expect(stale.data.content).toContain('Progress changed');
|
expect(messageText(stale)).toContain('Progress changed');
|
||||||
d = await f.run(click(control(stale, 'Refresh')), guild);
|
d = await f.run(click(control(stale, 'Refresh')), guild);
|
||||||
expect(d.data.content).toContain('11/11 tasks · Complete');
|
expect(messageText(d)).toContain('11/11 tasks');
|
||||||
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`)).complete).toBe(false);
|
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 });
|
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 () => {
|
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');
|
const next = control(t, 'Next');
|
||||||
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
|
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
|
||||||
const refreshed = await f.run(click(next));
|
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 () => {
|
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
|
||||||
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
|
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);
|
const d = await open(h.id);
|
||||||
f.setDiscordTime('2026-09-04T22:00:00Z');
|
f.setDiscordTime('2026-09-04T22:00:00Z');
|
||||||
const expired = await f.run(click(control(d, 'Mark complete')));
|
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')));
|
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);
|
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 () => {
|
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(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');
|
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
|
||||||
for (const guild of [false, true]) {
|
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);
|
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||||
expect(flow.data.content).toContain('Walk');
|
expect(messageText(flow)).toContain('Walk');
|
||||||
}
|
}
|
||||||
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
|
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');
|
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'));
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,23 +6,59 @@ import type { AppDatabase } from '../auth';
|
|||||||
import { progressInput } from '../habits/contracts';
|
import { progressInput } from '../habits/contracts';
|
||||||
import type { Context, ControlData, Reply } from './types';
|
import type { Context, ControlData, Reply } from './types';
|
||||||
import { fields } 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 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])]);
|
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) {
|
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
|
||||||
const date = localDate(now, timezone);
|
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) })];
|
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 {
|
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.');
|
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 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 = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
|
const page = clampPage(data.page, all.length, dashboardSize), complete = all.filter(d => d.complete).length;
|
||||||
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.'}`,
|
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.'}`)])];
|
||||||
[...(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');
|
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 {
|
export function detail(c: Context, data: ControlData): Reply {
|
||||||
const date = data.date ?? c.service.today;
|
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 && 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);
|
const d = c.service.day(data.habitId!, date);
|
||||||
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
|
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
|
||||||
const state: ControlData = { ...data, expected: snapshot(d) };
|
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!);
|
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 (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
|
||||||
if (action === 'daily.task') {
|
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.');
|
if (!task) throw new ApiError(404, 'Task not found.');
|
||||||
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
|
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
|
||||||
} else {
|
} else {
|
||||||
const amount = fields(c.interaction).amount;
|
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.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;
|
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||||
const invalid = error instanceof ZodError;
|
const invalid = error instanceof ZodError;
|
||||||
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
|
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,
|
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);
|
[row(recover, button(c, 'Today', 'today'))], false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function help(guild: boolean, origin: string): Reply {
|
|||||||
'',
|
'',
|
||||||
...commands.map(command => `**/${command.name}** — ${command.description}`),
|
...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.',
|
'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.',
|
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test';
|
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';
|
import { commands } from './commands';
|
||||||
let f: ReturnType<typeof discordFixture>;
|
let f: ReturnType<typeof discordFixture>;
|
||||||
afterEach(() => f?.close());
|
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 () => {
|
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);
|
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);
|
const today = await f.run({ data: { name: 'today' } }, guild);
|
||||||
expect(today.data.flags).toBe(guild ? 64 : undefined);
|
expect(today.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
|
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);
|
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);
|
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
|
||||||
f.restart(); await f.requestDiscord(click);
|
f.restart(); await f.requestDiscord(click);
|
||||||
@@ -26,20 +26,20 @@ describe('signed Discord interactions', () => {
|
|||||||
expect(f.changed).toEqual(['alice']);
|
expect(f.changed).toEqual(['alice']);
|
||||||
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
|
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);
|
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 () => {
|
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);
|
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
|
||||||
const a = await f.run({ data: { name: 'today' } });
|
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);
|
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Details'), values: [h.id] } }, true, f.bob);
|
||||||
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
|
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);
|
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 () => {
|
test('old daily buttons cannot correct history accidentally', async () => {
|
||||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||||
const t = await f.run({ data: { name: 'today' } });
|
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');
|
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.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);
|
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||||
|
|||||||
@@ -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); } };
|
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 {
|
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)}`);
|
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
|
||||||
return item.custom_id;
|
return item.custom_id;
|
||||||
}
|
}
|
||||||
export function submit(custom_id: string, values: Record<string, string>) {
|
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 }] })) } };
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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 Interaction = z.infer<typeof interactionSchema>;
|
||||||
export type DiscordComponent = { type: number; [key: string]: unknown };
|
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 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 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) {
|
export function fields(i: Interaction) {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|||||||
@@ -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 }));
|
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
|
||||||
return buttons.length ? [row(...buttons)] : [];
|
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: [] } : {}) } };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user