Extend native Discord command screens and same-message navigation
This commit is contained in:
@@ -36,8 +36,8 @@ no signed interaction endpoint configured. Global command registration and the
|
||||
Discord interaction endpoint must be configured separately before production use.
|
||||
## Preferences
|
||||
|
||||
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
|
||||
paginate ten named schedules at a time; entries with matching name and schedule
|
||||
`preferences.ts` implements global `/settings` and `/reminders`. Reminder panels
|
||||
paginate six named schedules at a time; entries with matching name and schedule
|
||||
are edited together as multiple comma-separated 24-hour times. Forms accept
|
||||
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
|
||||
all schedules. Removing the last schedule disables delivery and stores an empty
|
||||
@@ -60,14 +60,15 @@ the website and reminder worker. Direct route fixtures default it to false.
|
||||
No new environment variables, migrations, outbound requests, or remote command
|
||||
registration are needed by this module. Global command registration remains an
|
||||
integration step. Successful mutations invoke the supplied live-card dirty hook.
|
||||
`progress.ts` implements `/progress`: six-habit text pages, focused private
|
||||
`progress.ts` implements `/progress`: six-habit component pages, focused private
|
||||
previews, explicit destination confirmation for snapshots, and live-image
|
||||
start/status/pause/resume. API composition injects the same `sharing.send` and
|
||||
`live.start/pause/state` operations used by website routes. Keep these shared
|
||||
instances when reconciling dispatch; do not construct a second live worker.
|
||||
|
||||
Rendering and network actions return ephemeral type-5 acknowledgements and edit
|
||||
the original interaction response once. `DeferredDispatch.complete` and
|
||||
Progress rendering and network actions acknowledge existing message interactions
|
||||
with type 6 and edit the original message once; a flow without a source message
|
||||
uses type 5 (ephemeral in servers). `DeferredDispatch.complete` and
|
||||
`schedule` are injectable for tests. Interaction tokens live only in memory,
|
||||
never receipts or controls. A process interruption after acknowledgement can
|
||||
leave the private response unfinished; restarting never replays the public send.
|
||||
@@ -116,12 +117,11 @@ showing the selected date's requirements, task completion, carryover where
|
||||
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
|
||||
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
|
||||
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
|
||||
Management forms and `/help` remain text-based because their controls already
|
||||
communicate the relevant choices.
|
||||
Management uses native component panels and forms. `/help` remains text-based.
|
||||
|
||||
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
|
||||
request a fresh owned snapshot. Regular check-ins keep their existing immediate
|
||||
text responses. Graphic actions acknowledge before rendering and attach a PNG to
|
||||
request a fresh owned snapshot. Regular check-ins use immediate
|
||||
component updates. Graphic actions acknowledge before rendering and attach a PNG to
|
||||
a new private reply; they never publish to the sharing channel or write progress.
|
||||
Each image has a text equivalent and attachment description. A render failure
|
||||
retains the text and navigation. No new command registration or configuration is
|
||||
@@ -143,10 +143,41 @@ Navigation, saves, and recoverable daily validation errors update the originatin
|
||||
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.
|
||||
PNG cards still open as separate snapshots. Historical editors reuse the same component check-in screens while retaining
|
||||
their dated requirements.
|
||||
|
||||
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.
|
||||
|
||||
## Component command screens
|
||||
|
||||
`/habits`, `/history`, `/reminders`, and `/progress` share the native `screen`,
|
||||
`panel`, and `section` builders in `ui.ts`. Six entries per page leave room for
|
||||
navigation and actions within Discord's 40-component limit. Habit panels use the
|
||||
existing saved habit colors; general screens use the same Mina accent as /today.
|
||||
Each entry has an inline Manage, Open history, Manage reminder, or Preview action.
|
||||
Settings and task-management screens also use native panels so navigation from
|
||||
these commands never attempts to downgrade a V2 message.
|
||||
|
||||
History opens the selected date directly, with Previous/Next date and Change date
|
||||
controls. Its checklists paginate six tasks to leave room for date navigation.
|
||||
Manual/count/task saves still use the shared daily handlers and dated requirements.
|
||||
|
||||
Normal navigation, forms, confirmations, and recoverable errors edit the source
|
||||
message. Only validated owner controls can update it; expired or cross-account
|
||||
controls receive a separate private error. Existing dropdown controls remain
|
||||
supported until they expire. No new command registration or migration is needed.
|
||||
|
||||
Progress previews include a V2 media gallery referencing the uploaded attachment.
|
||||
The deferred PATCH preserves the V2 flag, clears legacy content/embeds, and replaces
|
||||
or removes prior attachments. Theme changes, destination confirmation, live status,
|
||||
and public-send results all stay in the private source message. Public sends still
|
||||
require explicit destination confirmation and retain the shared quotas and replay
|
||||
guards. Optional day/check-in/schedule PNG cards remain separate private snapshots.
|
||||
|
||||
`components.test.ts` checks layout limits and same-message form/navigation flows,
|
||||
plus deferred attachment transport. `progress.test.ts` covers preview/theme/back
|
||||
navigation, failures, and the existing sharing protections. Live client appearance
|
||||
still requires a Discord desktop/mobile acceptance check.
|
||||
|
||||
@@ -48,7 +48,8 @@ test('graphics retain ownership, expiry, pagination and selected historical requ
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
|
||||
const history = await f.run({ data: { name: 'history' } }, true);
|
||||
const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true);
|
||||
const editor = await f.run(click(control(history, 'Open history')), true);
|
||||
const form = await f.run(click(control(editor, 'Change date')), true);
|
||||
const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
|
||||
const id = control(day, 'View check-in card');
|
||||
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
|
||||
|
||||
126
src/discord/components.test.ts
Normal file
126
src/discord/components.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { components, control, discordFixture, findHabitPage, habitControl, messageText, submit } from './test-fixture';
|
||||
import { completeDeferred } from './routes';
|
||||
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
|
||||
function native(reply: any, type = 7) {
|
||||
expect(reply.type).toBe(type);
|
||||
expect(reply.data.flags & 32768).toBe(32768);
|
||||
expect(reply.data.content ?? null).toBeNull();
|
||||
expect(reply.data.embeds ?? []).toEqual([]);
|
||||
const all = components(reply.data.components);
|
||||
expect(all.length).toBeLessThanOrEqual(40);
|
||||
expect(all.filter(c => c.custom_id).map(c => c.custom_id).length).toBe(new Set(all.filter(c => c.custom_id).map(c => c.custom_id)).size);
|
||||
expect(all.filter(c => c.type === 10).map(c => c.content).join('').length).toBeLessThanOrEqual(4000);
|
||||
for (const c of all) {
|
||||
if (c.type === 1) expect(c.components.length).toBeLessThanOrEqual(5);
|
||||
if (c.type === 9) {
|
||||
expect(c.components.every((child: any) => child.type === 10)).toBe(true);
|
||||
expect(c.accessory.type).toBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const guild of [false, true]) test(`command lists use bounded native layouts and update paginated messages (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture(true);
|
||||
for (let i = 0; i < 14; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual', color: '#123456' }, 201);
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders: Array.from({ length: 20 }, (_, i) => ({ name: `Reminder ${i}`, type: 'weekly', days: [1, 3, 5], time: '12:00' })), quietStart: '22:00', quietEnd: '08:00' });
|
||||
for (const command of ['habits', 'history', 'progress', 'reminders']) {
|
||||
let list = await f.run({ data: { name: command } }, guild);
|
||||
native(list, 4);
|
||||
expect(list.data.flags & 64).toBe(guild ? 64 : 0);
|
||||
const titles = new Set<string>();
|
||||
for (let page = 0; page < 4; page++) {
|
||||
native(list, page ? 7 : 4);
|
||||
for (const section of components(list.data.components).filter(c => c.type === 9)) titles.add(section.components[0].content.split('\n')[0]);
|
||||
if (!components(list.data.components).some(c => c.label === 'Next')) break;
|
||||
list = await f.run(click(list, 'Next'), guild);
|
||||
}
|
||||
expect(titles.size).toBe(command === 'reminders' ? 20 : 14);
|
||||
native(await f.run(click(list, 'Previous'), guild));
|
||||
if (command !== 'reminders') expect(list.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x123456)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('habit forms, confirmations, stale errors and recovery retain the original message', async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#abcdef' }, 201);
|
||||
let list = await f.run({ data: { name: 'habits' } }, true);
|
||||
let detail = await f.run(click(list, 'Manage'), true);
|
||||
native(detail);
|
||||
expect(detail.data.components[0].accent_color).toBe(0xabcdef);
|
||||
let form = await f.run(click(detail, 'Edit details'), true);
|
||||
detail = await f.run(submit(form.data.custom_id, { name: 'Books', color: '#123456' }), true);
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Books');
|
||||
const confirmation = await f.run(click(detail, 'Archive habit'), true);
|
||||
native(confirmation);
|
||||
detail = await f.run(click(confirmation, 'Confirm'), true);
|
||||
native(detail);
|
||||
list = await f.run(click(detail, 'Back to habits'), true);
|
||||
expect(messageText(list)).toContain('Archived habits');
|
||||
detail = await f.run(click(list, 'Manage'), true);
|
||||
form = await f.run(click(detail, 'Edit details'), true);
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Website edit' });
|
||||
const failed = await f.run(submit(form.data.custom_id, { name: 'Old edit', color: '#123456' }), true);
|
||||
native(failed);
|
||||
native(await f.run(click(failed, 'Back to habits'), true));
|
||||
});
|
||||
|
||||
test('history checklists retain date controls, safe component limits and same-message saves', async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
|
||||
const list = await f.run({ data: { name: 'history' } });
|
||||
let detail = await f.run(click(list, 'Open history'));
|
||||
native(detail);
|
||||
detail = await f.run(click(detail, 'Next'));
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Page 2 of 4');
|
||||
detail = await f.run(click(detail, 'Complete'));
|
||||
native(detail);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
|
||||
const form = await f.run(click(detail, 'Change date'));
|
||||
detail = await f.run(submit(form.data.custom_id, { date: '2026-09-05' }));
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Future dates are read-only');
|
||||
expect(components(detail.data.components).some(c => c.label === 'Complete')).toBe(false);
|
||||
});
|
||||
|
||||
test('reminder edits, validation retries and removal stay in the original message', async () => {
|
||||
f = discordFixture(true);
|
||||
let list = await f.run({ data: { name: 'reminders' } }, true);
|
||||
let form = await f.run(click(list, 'Add reminder'), true);
|
||||
const draft = { name: 'Evening', times: '99:00', days: 'every day', date: '' };
|
||||
const error = await f.run(submit(form.data.custom_id, draft), true);
|
||||
native(error);
|
||||
form = await f.run(click(error, 'Try again'), true);
|
||||
list = await f.run(submit(form.data.custom_id, { ...draft, times: '18:00' }), true);
|
||||
native(list);
|
||||
const page = await findHabitPage(f, list, 'Evening', true);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(page, 'Evening', 'Manage reminder') } }, true);
|
||||
native(detail);
|
||||
const confirmation = await f.run(click(detail, 'Remove reminder'), true);
|
||||
native(confirmation);
|
||||
native(await f.run(click(confirmation, 'Confirm removal'), true));
|
||||
});
|
||||
|
||||
test('deferred native previews PATCH the original with V2 flags, gallery and attachment; text screens clear old media', async () => {
|
||||
f = discordFixture();
|
||||
const interaction = f.payload({ type: 3, message: { id: '123' } }, true);
|
||||
const requests: { url: string; payload: any; image: FormDataEntryValue | null }[] = [];
|
||||
const request = async (url: string, init: RequestInit) => {
|
||||
expect(init.method).toBe('PATCH');
|
||||
const body = init.body as FormData;
|
||||
requests.push({ url, payload: JSON.parse(String(body.get('payload_json'))), image: body.get('files[0]') });
|
||||
return Response.json({});
|
||||
};
|
||||
const gallery = { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] };
|
||||
await completeDeferred(interaction, { message: { flags: 32768 | 64, components: [gallery] }, image: new Uint8Array([1, 2, 3]), imageDescription: 'Progress preview' }, request);
|
||||
expect(requests[0]!.url).toEndWith('/messages/@original');
|
||||
expect(requests[0]!.payload).toMatchObject({ flags: 32768, content: null, embeds: [], components: [gallery], attachments: [{ id: 0, filename: 'mina-progress.png', description: 'Progress preview' }] });
|
||||
expect(requests[0]!.image).toBeInstanceOf(Blob);
|
||||
await completeDeferred(interaction, { message: { flags: 32768, components: [{ type: 10, content: 'Back to progress' }] } }, request);
|
||||
expect(requests[1]!.payload.attachments).toEqual([]);
|
||||
expect(requests[1]!.image).toBeNull();
|
||||
});
|
||||
@@ -39,18 +39,19 @@ export function today(c: Context, data: ControlData = {}): Reply {
|
||||
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 {
|
||||
function checkInDetail(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}` : ''}`)];
|
||||
const size = data.mode === 'history' ? 6 : taskSize;
|
||||
const page = clampPage(data.page, d.tasks.length, size);
|
||||
const state = { ...data, mode: data.mode === 'history' ? 'history' : 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 on this date'}${d.carriedFrom ? `\nCarried from ${d.carriedFrom}` : ''}${d.future ? '\nFuture dates are read-only' : ''}`)];
|
||||
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));
|
||||
for (const task of d.tasks.slice(page * size, (page + 1) * size)) 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 > size) components.push(display(`Page ${page + 1} of ${Math.ceil(d.tasks.length / size)}`));
|
||||
components.push(...pages(c, 'daily.detail', state, d.tasks.length, size));
|
||||
}
|
||||
}
|
||||
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 }))]);
|
||||
@@ -58,7 +59,7 @@ function todayDetail(c: Context, data: ControlData): Reply {
|
||||
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 });
|
||||
if (data.today || data.mode === 'history') return checkInDetail(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) };
|
||||
@@ -103,7 +104,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'))]);
|
||||
if (data.today || data.mode === 'history') 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);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { control, discordFixture, submit } from './test-fixture';
|
||||
import { control, discordFixture, submit, messageText, components, habitControl, findHabitPage, taskControl } 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 ?? []);
|
||||
const items = (reply: any): any[] => components(reply.data.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]);
|
||||
const h = await f.json(`/habits/${habitId}`);
|
||||
const list = await findHabitPage(f, await f.run({ data: { name: 'history' } }, guild), h.name, guild);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Open history') } }, guild);
|
||||
const form = await click(detail, 'Change date', guild);
|
||||
expect(form.type).toBe(9);
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
@@ -19,7 +21,7 @@ async function changeDate(reply: any, date: string, guild = false) {
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
function readOnly(reply: any) {
|
||||
expect(reply.data.content).toContain('Read-only');
|
||||
expect(messageText(reply)).toContain('Read-only');
|
||||
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
|
||||
}
|
||||
|
||||
@@ -44,33 +46,33 @@ describe('/history', () => {
|
||||
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');
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
expect(messageText(list)).toContain('Archived');
|
||||
let d = await open(h.id, '2026-09-04', guild);
|
||||
expect(d.data.content).toContain('0/5 pages');
|
||||
expect(messageText(d)).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(messageText(d)).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');
|
||||
expect(messageText(d)).toContain('0/20 chapters');
|
||||
d = await click(d, 'Next date', guild);
|
||||
expect(d.data.content).toContain('Checkbox');
|
||||
expect(messageText(d)).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(messageText(d)).toContain('Original task');
|
||||
expect(messageText(d)).not.toContain('New task');
|
||||
d = await click(d, 'Complete', guild);
|
||||
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(messageText(d)).toContain('archived on this date');
|
||||
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
|
||||
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
|
||||
});
|
||||
@@ -79,21 +81,21 @@ describe('/history', () => {
|
||||
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');
|
||||
expect(messageText(d)).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');
|
||||
expect(messageText(d)).toContain('real calendar date');
|
||||
}
|
||||
d = await changeDate(d, '2024-02-29');
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('not tracked yet');
|
||||
expect(messageText(d)).toContain('not tracked yet');
|
||||
d = await changeDate(d, '2026-09-05');
|
||||
expect(d.data.content).toContain('Future dates are read-only');
|
||||
expect(messageText(d)).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');
|
||||
expect(messageText(d)).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');
|
||||
@@ -106,19 +108,18 @@ describe('/history', () => {
|
||||
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);
|
||||
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
|
||||
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' }));
|
||||
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
|
||||
expect(messageText((await click(list, 'Previous')))).toContain('Page 1 of 3');
|
||||
let d = await open(h.id, '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');
|
||||
expect(messageText(d)).toContain('○ Task 6');
|
||||
expect(items(d).length).toBeLessThanOrEqual(40);
|
||||
d = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: taskControl(d, 'Task 10') } });
|
||||
expect(messageText(d)).toContain('✓ Task 10');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).toContain('✓ Task 10');
|
||||
expect(messageText(d)).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);
|
||||
});
|
||||
@@ -128,14 +129,15 @@ describe('/history', () => {
|
||||
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');
|
||||
const rejected = await click(a, 'Open history', guild, [h.id], f.bob);
|
||||
expect(messageText(rejected)).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');
|
||||
// Inline controls bind the habit ID; injected select values cannot redirect them.
|
||||
expect(messageText(await click(b, 'Open history', guild, [h.id], f.bob))).toContain('Bobs habit');
|
||||
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(messageText((await click(d, 'Mark complete', guild, undefined, f.bob)))).toContain('belongs to another user');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(f.changed).toEqual([]);
|
||||
});
|
||||
@@ -147,20 +149,20 @@ describe('/history', () => {
|
||||
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');
|
||||
expect(messageText((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))))).toContain('Progress changed');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).toContain('8/10');
|
||||
expect(messageText(d)).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(messageText((await click(d, '+1')))).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');
|
||||
expect(messageText((await click(d, '+1')))).toContain('expired');
|
||||
});
|
||||
|
||||
test('uses account-local dates at midnight and retains historical record timezone', async () => {
|
||||
@@ -168,31 +170,31 @@ describe('/history', () => {
|
||||
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]);
|
||||
const form = await click(await click(list, 'Open history'), 'Change date');
|
||||
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(messageText(d)).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([]);
|
||||
expect(messageText(empty)).toContain('No habits yet');
|
||||
expect(items(empty).filter(c => c.type === 2)).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');
|
||||
expect(messageText(d)).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');
|
||||
expect(messageText(d)).toContain('Monday');
|
||||
d = await changeDate(d, '2026-09-07');
|
||||
expect(d.data.content).toContain('Future dates are read-only');
|
||||
expect(messageText(d)).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);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function scheduleLabel(schedule: Schedule): string {
|
||||
@@ -14,11 +14,15 @@ function scheduleLabel(schedule: Schedule): string {
|
||||
|
||||
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)]);
|
||||
const page = listPage(data.page, all.length);
|
||||
const state: ControlData = { date: data.date ?? c.service.today, mode: 'history', today: false };
|
||||
return componentMessage(c, [
|
||||
panel([display(`## History\n${text(c.service.user.timezone)} · ${pageLabel(page, all.length)}\n${all.length ? 'Open a habit to inspect or correct a date. Archived habits are included.' : 'No habits yet. Use /habits to create one.'}`)]),
|
||||
...all.slice(page * listSize, (page + 1) * listSize).map(h => panel([
|
||||
section(`**${text(h.name)}**\n${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}`, button(c, 'Open history', 'history.detail', { ...state, habitId: h.id })),
|
||||
], c.service.settings(h.id).mainColor)),
|
||||
...pages(c, 'history', { ...state, page }, all.length, listSize),
|
||||
]);
|
||||
}
|
||||
|
||||
function dateForm(c: Context, data: ControlData): Reply {
|
||||
@@ -43,7 +47,13 @@ function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
|
||||
: !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)] } };
|
||||
const [card, ...rest] = body.components ?? [];
|
||||
if (card?.type === 17) {
|
||||
const children = card.components as import('./types').DiscordComponent[];
|
||||
const metadata = `${requirements}${explanation}`.trim();
|
||||
return { ...reply, data: { ...body, components: [{ ...card, components: [children[0]!, ...(metadata ? [display(metadata)] : []), ...children.slice(1)] }, ...rest, row(...navigation)] } };
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
@@ -52,7 +62,7 @@ export function historyAction(c: Context, action: string, data: ControlData): Re
|
||||
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))]);
|
||||
if (!parsed.success) return screen(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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components, habitControl } from './test-fixture';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
@@ -7,7 +7,7 @@ async function click(reply: any, label: string, guild = false, values?: string[]
|
||||
}
|
||||
async function list(id: string, guild = false) {
|
||||
const home = await f.run({ data: { name: 'tasks' } }, guild);
|
||||
expect(home.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(home.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
return click(home, 'Choose a task habit', guild, [id]);
|
||||
}
|
||||
async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); }
|
||||
@@ -30,7 +30,7 @@ for (const guild of [false, true]) test(`task add, rename, schedule and delete m
|
||||
detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild);
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 });
|
||||
const confirm = await click(detail, 'Delete task', guild);
|
||||
expect(confirm.data.content).toContain('completion total may change');
|
||||
expect(messageText(confirm)).toContain('completion total may change');
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
|
||||
await click(confirm, 'Confirm task deletion', guild);
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0);
|
||||
@@ -46,12 +46,12 @@ test('task scheduling supports daily, selected weekdays, day intervals and overl
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' });
|
||||
let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
|
||||
detail = await click(menu, 'Select weekdays', false, ['2']);
|
||||
expect(detail.data.content).toContain('never coincides');
|
||||
expect(messageText(detail)).toContain('never coincides');
|
||||
menu = await click(detail, 'Change task schedule');
|
||||
detail = await click(menu, 'Recurrence', false, ['daily']);
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
|
||||
form = await click(detail, 'Rename task');
|
||||
expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: ' ' }))))).toContain('Check the entered');
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan');
|
||||
});
|
||||
test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => {
|
||||
@@ -59,18 +59,18 @@ test('task changes preserve prior dates; stale controls and cross-owner task IDs
|
||||
const id = h.tasks[0].id;
|
||||
const rename = await click(await task(h.id, id), 'Rename task');
|
||||
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' });
|
||||
expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))))).toContain('habit changed');
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0);
|
||||
const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b');
|
||||
const add = await click(await list(h.id), 'Add task');
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)))).toContain('belongs to another');
|
||||
const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob);
|
||||
expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another');
|
||||
expect(messageText((await click(bobMenu, 'Choose a task habit', false, [bob.id])))).toContain('belongs to another');
|
||||
const aliceMenu = await f.run({ data: { name: 'tasks' } });
|
||||
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
|
||||
expect(messageText((await click(aliceMenu, 'Choose a task habit', false, [bob.id])))).toContain('available options');
|
||||
});
|
||||
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
|
||||
f = discordFixture(); const h = await setup();
|
||||
@@ -81,9 +81,9 @@ test('add forms are replay safe after restart and task pagination stays within l
|
||||
expect(f.changed).toHaveLength(1);
|
||||
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201);
|
||||
const first = await list(h.id);
|
||||
expect(first.data.components[0].components[0].options).toHaveLength(10);
|
||||
expect(components(first.data.components).find(c => c.type === 3).options).toHaveLength(10);
|
||||
const next = await click(first, 'Next');
|
||||
expect(next.data.components[0].components[0].options).toHaveLength(2);
|
||||
expect(components(next.data.components).find(c => c.type === 3).options).toHaveLength(2);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
|
||||
@@ -91,16 +91,16 @@ test('midnight, color-only website edits, and count progress invalidate manageme
|
||||
const h = await setup();
|
||||
let add = await click(await list(h.id), 'Add task');
|
||||
f.setDiscordTime('2026-09-04T22:01:00Z');
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))))).toContain('habit changed');
|
||||
add = await click(await list(h.id), 'Add task');
|
||||
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' });
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Old color' }))))).toContain('habit changed');
|
||||
const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
|
||||
const home = await f.run({ data: { name: 'habits' } });
|
||||
const detail = await click(home, 'Choose a habit to manage', false, [c.id]);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(home, 'Count', 'Manage') } });
|
||||
const form = await click(detail, 'Edit details');
|
||||
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 });
|
||||
expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))))).toContain('habit changed');
|
||||
});
|
||||
test('a complete checklist routine can be created entirely in Discord', async () => {
|
||||
f = discordFixture();
|
||||
@@ -111,7 +111,7 @@ test('a complete checklist routine can be created entirely in Discord', async ()
|
||||
const tasks = await click(habit, 'Manage tasks', true);
|
||||
const add = await click(tasks, 'Add task', true);
|
||||
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true);
|
||||
expect(task.data.content).toContain('Prepare tomorrow');
|
||||
expect(messageText(task)).toContain('Prepare tomorrow');
|
||||
const website = (await f.json('/habits')).habits[0];
|
||||
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
|
||||
const settings = await f.json(`/habits/${website.id}/calendar-settings`);
|
||||
@@ -119,5 +119,5 @@ test('a complete checklist routine can be created entirely in Discord', async ()
|
||||
const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
|
||||
const taskMenu = await list(website.id);
|
||||
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]);
|
||||
expect(blocked.data.content).toContain('available options');
|
||||
expect(messageText(blocked)).toContain('available options');
|
||||
});
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components, habitControl, findHabitPage } from './test-fixture';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild);
|
||||
}
|
||||
async function open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
|
||||
async function open(id: string, guild = false) {
|
||||
const h = await f.json(`/habits/${id}`);
|
||||
const list = await findHabitPage(f, await f.run({ data: { name: 'habits' } }, guild), h.name, guild);
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Manage') } }, guild);
|
||||
}
|
||||
async function create(method: string, guild = false) {
|
||||
const list = await f.run({ data: { name: 'habits' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const methodMenu = await click(list, 'Create habit', guild);
|
||||
const form = await click(methodMenu, 'Tracking method', guild, [method]);
|
||||
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
|
||||
expect(result.data.content).toContain('Read');
|
||||
expect(messageText(result)).toContain('Read');
|
||||
return { result, habit: (await f.json('/habits')).habits[0] };
|
||||
}
|
||||
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
@@ -30,9 +34,9 @@ for (const guild of [false, true]) test(`habit lifecycle and shared website stat
|
||||
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
|
||||
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
|
||||
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
|
||||
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
|
||||
expect(control(archived, 'Manage')).toBeTruthy();
|
||||
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
|
||||
expect(detail.data.content).toContain('No habits');
|
||||
expect(messageText(detail)).toContain('No habits');
|
||||
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
|
||||
expect(f.changed.length).toBe(7);
|
||||
});
|
||||
@@ -54,7 +58,7 @@ test('all recurrences use validated friendly controls', async () => {
|
||||
await click(menu, 'Recurrence', false, ['daily']);
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
|
||||
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
|
||||
expect(messageText((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))))).toContain('Check the entered');
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||
});
|
||||
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
|
||||
@@ -63,7 +67,7 @@ test('method conversion warns, resets current progress and preserves past dated
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
const menu = await click(await open(habit.id), 'Change method');
|
||||
const confirmation = await click(menu, 'New tracking method', false, ['count']);
|
||||
expect(confirmation.data.content).toContain('resets today');
|
||||
expect(messageText(confirmation)).toContain('resets today');
|
||||
const form = await click(confirmation, 'Continue conversion');
|
||||
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
|
||||
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
|
||||
@@ -78,21 +82,23 @@ test('stale, replayed, and cross-account management controls cannot write', asyn
|
||||
f = discordFixture(); const { result, habit } = await create('manual');
|
||||
const form = await click(result, 'Edit details');
|
||||
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
|
||||
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))))).toContain('habit changed');
|
||||
const fresh = await click(await open(habit.id), 'Edit details');
|
||||
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
|
||||
await f.requestDiscord(payload); const changes = f.changed.length;
|
||||
f.restart(); await f.requestDiscord(payload);
|
||||
expect(f.changed.length).toBe(changes);
|
||||
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
|
||||
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
|
||||
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))))).toContain('expired');
|
||||
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)))).toContain('belongs to another');
|
||||
expect((await f.json('/habits')).habits[0].name).toBe('Final');
|
||||
});
|
||||
test('pagination keeps Discord component and option limits', async () => {
|
||||
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||
const page = await f.run({ data: { name: 'habits' } });
|
||||
expect(page.data.components[0].components[0].options.length).toBe(10);
|
||||
expect(components(page.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
|
||||
expect(components(page.data.components).length).toBeLessThanOrEqual(40);
|
||||
const next = await click(page, 'Next');
|
||||
expect(next.data.components[0].components[0].options.length).toBe(2);
|
||||
expect(components(next.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
|
||||
expect(components(next.data.components).length).toBeLessThanOrEqual(40);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { fields, type Context, type ControlData, type Reply } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, screen, modal, pages, row, select, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const prefix = 'manage:';
|
||||
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
|
||||
@@ -31,22 +31,25 @@ export function scheduleLabel(s: Schedule): string {
|
||||
export function manageHabits(c: Context, d: ControlData = {}): Reply {
|
||||
const archived = d.mode === 'archived';
|
||||
const habits = c.service.list(true).filter(h => h.archived === archived);
|
||||
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
|
||||
const page = listPage(d.page, habits.length);
|
||||
const state = { mode: archived ? 'archived' : 'active', page };
|
||||
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
|
||||
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
|
||||
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
|
||||
...pages(c, prefix + 'list', state, habits.length),
|
||||
return componentMessage(c, [
|
||||
panel([display(`## ${archived ? 'Archived' : 'Active'} habits\n${pageLabel(page, habits.length)} · ${habits.length} habits\n${habits.length ? 'Edit details, schedules, and tracking methods.' : 'No habits here yet.'}`)]),
|
||||
...habits.slice(page * listSize, (page + 1) * listSize).map(h => panel([
|
||||
section(`**${text(h.name)}**\n${scheduleLabel(h.schedule)} · ${h.method === 'count' ? `${h.target} ${text(h.unit)}` : h.method === 'tasks' ? 'Checklist' : 'Manual check-in'}`, button(c, 'Manage', prefix + 'detail', { habitId: h.id })),
|
||||
], c.service.settings(h.id).mainColor)),
|
||||
...pages(c, prefix + 'list', state, habits.length, listSize),
|
||||
row(button(c, 'Create habit', prefix + 'create', {}, 1), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
|
||||
]);
|
||||
}
|
||||
function detail(c: Context, id: string): Reply {
|
||||
const h = c.service.current(id), d = snapshot(c, id);
|
||||
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
|
||||
return screen(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
|
||||
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
|
||||
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
|
||||
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
|
||||
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
|
||||
]);
|
||||
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list', { mode: h.archived ? 'archived' : 'active' })),
|
||||
], true, c.service.settings(id).mainColor);
|
||||
}
|
||||
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
|
||||
@@ -58,7 +61,7 @@ function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||
}
|
||||
function schedulePicker(c: Context, d: ControlData): Reply {
|
||||
check(c, d);
|
||||
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
return screen(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
}
|
||||
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
|
||||
check(c, d);
|
||||
@@ -74,7 +77,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
if (a === 'list') return manageHabits(c, d);
|
||||
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
|
||||
if (a === 'detail') return detail(c, d.habitId!);
|
||||
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
|
||||
if (a === 'create') return screen(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods), row(button(c, 'Back to habits', prefix + 'list'))]);
|
||||
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
|
||||
if (a === 'save-create') {
|
||||
const f = fields(c.interaction);
|
||||
@@ -88,10 +91,10 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
consume(c); return detail(c, d.habitId!);
|
||||
}
|
||||
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
|
||||
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
|
||||
if (a === 'method') { const h = check(c, d); return screen(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method)), row(button(c, 'Cancel', prefix + 'detail', d))]); }
|
||||
if (a === 'method-confirm') {
|
||||
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
|
||||
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
return screen(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
}
|
||||
if (a === 'convert') {
|
||||
check(c, d);
|
||||
@@ -100,7 +103,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
}
|
||||
if (a === 'confirm') {
|
||||
const h = check(c, d);
|
||||
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
return screen(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
}
|
||||
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
|
||||
if (a === 'schedule') return schedulePicker(c, d);
|
||||
@@ -110,7 +113,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
if (kind === 'weekdays') {
|
||||
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
|
||||
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
|
||||
return message(c, 'Select all days on which this repeats.', [menu]);
|
||||
return screen(c, 'Select all days on which this repeats.', [menu, row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
}
|
||||
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
|
||||
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
|
||||
@@ -132,7 +135,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
export function manageTasks(c: Context, d: ControlData = {}): Reply {
|
||||
const habits = c.service.list().filter(h => h.method === 'tasks');
|
||||
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
|
||||
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
|
||||
return screen(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
|
||||
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
|
||||
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
|
||||
...pages(c, prefix + 'tasks-home', { page }, habits.length),
|
||||
@@ -143,7 +146,7 @@ function taskList(c: Context, d: ControlData): Reply {
|
||||
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
|
||||
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
|
||||
const state = { ...snapshot(c, h.id), page };
|
||||
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
|
||||
return screen(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
|
||||
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
|
||||
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
|
||||
...pages(c, prefix + 'tasks-list', state, tasks.length),
|
||||
@@ -155,7 +158,7 @@ function taskDetail(c: Context, d: ControlData): Reply {
|
||||
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
|
||||
const state = { ...snapshot(c, h.id), taskId: task.id };
|
||||
const warning = h.warnings.find(w => w.taskId === task.id);
|
||||
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
|
||||
return screen(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
|
||||
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
|
||||
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
|
||||
]);
|
||||
@@ -181,7 +184,7 @@ function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
|
||||
if (a === 'task-delete') {
|
||||
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
|
||||
if (!task) throw new ApiError(404, 'Task not found.');
|
||||
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
|
||||
return screen(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
|
||||
}
|
||||
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { reminderDeliveries, users } from '../db/schema';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components } from './test-fixture';
|
||||
import { commands } from './commands';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
|
||||
const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' };
|
||||
async function choose(list: any, guild = false, index = '0') {
|
||||
return f.run({ ...click(list, 'Choose a reminder'), data: { custom_id: control(list, 'Choose a reminder'), values: [index] } }, guild);
|
||||
return f.run(click(list, 'Manage reminder'), guild);
|
||||
}
|
||||
test('settings and reminders are global commands in both contexts', () => {
|
||||
for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]);
|
||||
@@ -17,7 +17,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
|
||||
f = discordFixture(true);
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' });
|
||||
let list = await f.run({ data: { name: 'reminders' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const form = await f.run(click(list, 'Add reminder'), guild);
|
||||
const interaction = f.payload(submit(form.data.custom_id, draft), guild);
|
||||
list = await (await f.requestDiscord(interaction)).json();
|
||||
@@ -40,7 +40,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
|
||||
list = await f.run(click(confirmation, 'Confirm removal'), guild);
|
||||
expect((await f.json('/reminders')).reminders).toEqual([]);
|
||||
expect((await f.json('/reminders')).enabled).toBe(false);
|
||||
expect(list.data.content).toContain('No reminders');
|
||||
expect(messageText(list)).toContain('No reminders');
|
||||
const settings = await f.run({ data: { name: 'settings' } }, guild);
|
||||
const tz = await f.run(click(settings, 'Change timezone'), guild);
|
||||
await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild);
|
||||
@@ -55,20 +55,20 @@ test('bad input can reopen a prefilled form without consuming the valid control'
|
||||
let form = await f.run(click(list, 'Add reminder'));
|
||||
for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) {
|
||||
const failed = await f.run(submit(form.data.custom_id, invalid));
|
||||
expect(failed.type).toBe(4);
|
||||
expect(failed.type).toBe(7);
|
||||
form = await f.run(click(failed, 'Try again'));
|
||||
expect(form.type).toBe(9);
|
||||
expect(form.data.components[0].components[0].value).toBe(draft.name);
|
||||
}
|
||||
const saved = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(saved.data.content).toContain(draft.name);
|
||||
expect(messageText(saved)).toContain(draft.name);
|
||||
const enabled = await f.run(click(saved, 'Enable delivery'));
|
||||
expect(enabled.data.content).toContain('not configured');
|
||||
expect(messageText(enabled)).toContain('not configured');
|
||||
expect((await f.json('/reminders')).enabled).toBe(false);
|
||||
const settings = await f.run({ data: { name: 'settings' } });
|
||||
const timezone = await f.run(click(settings, 'Change timezone'));
|
||||
const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' }));
|
||||
expect(invalid.data.content).toContain('Use a timezone');
|
||||
expect(messageText(invalid)).toContain('Use a timezone');
|
||||
const retry = await f.run(click(invalid, 'Try again'));
|
||||
await f.run(submit(retry.data.custom_id, { timezone: 'UTC' }));
|
||||
expect((await f.json('/me')).timezone).toBe('UTC');
|
||||
@@ -82,17 +82,17 @@ test('website edits and timezone changes invalidate old controls even within one
|
||||
const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' };
|
||||
await f.json('/reminders', 'PUT', prefs);
|
||||
const blocked = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(blocked.data.content).toContain('Settings changed');
|
||||
expect(messageText(blocked)).toContain('Settings changed');
|
||||
const newer = await f.run({ data: { name: 'reminders' } });
|
||||
const newerForm = await f.run(click(newer, 'Add reminder'));
|
||||
await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' });
|
||||
await f.json('/reminders', 'PUT', prefs);
|
||||
expect((await f.run(submit(newerForm.data.custom_id, draft))).data.content).toContain('Settings changed');
|
||||
expect(messageText((await f.run(submit(newerForm.data.custom_id, draft))))).toContain('Settings changed');
|
||||
const settings = await f.run({ data: { name: 'settings' } });
|
||||
const tz = await f.run(click(settings, 'Change timezone'));
|
||||
await f.json('/me', 'PATCH', { timezone: 'UTC' });
|
||||
await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' });
|
||||
expect((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))).data.content).toContain('Settings changed');
|
||||
expect(messageText((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))))).toContain('Settings changed');
|
||||
expect((await f.json('/me')).timezone).toBe('Europe/Belgrade');
|
||||
});
|
||||
|
||||
@@ -102,11 +102,11 @@ test('old forms cannot cross accounts; new Discord accounts discover UTC setup',
|
||||
const form = await f.run(click(list, 'Add reminder'));
|
||||
const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob);
|
||||
expect(blocked.data.flags).toBe(64);
|
||||
expect(blocked.data.content).toContain('belongs to another user');
|
||||
expect(messageText(blocked)).toContain('belongs to another user');
|
||||
expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1);
|
||||
const id = '555555555555555555';
|
||||
const settings = await f.run({ data: { name: 'settings' } }, false, id);
|
||||
expect(settings.data.content).toContain('New accounts start in UTC');
|
||||
expect(messageText(settings)).toContain('New accounts start in UTC');
|
||||
expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
@@ -116,19 +116,19 @@ test('paginated list, 20-slot limit, delivery status and quiet-hour validation',
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' });
|
||||
f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run();
|
||||
const list = await f.run({ data: { name: 'reminders' } });
|
||||
expect(list.data.content).toContain('Delivery unconfirmed');
|
||||
expect(messageText(list)).toContain('Delivery unconfirmed');
|
||||
const next = await f.run(click(list, 'Next'));
|
||||
expect(next.data.content).toContain('Reminder 20');
|
||||
expect(messageText(next)).toContain('Reminder 12');
|
||||
const form = await f.run(click(next, 'Add reminder'));
|
||||
const fail = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(fail.data.content).toContain('up to 20');
|
||||
expect(messageText(fail)).toContain('up to 20');
|
||||
expect((await f.json('/reminders')).reminders).toHaveLength(20);
|
||||
const quiet = await f.run(click(list, 'Quiet hours'));
|
||||
const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' }));
|
||||
const retry = await f.run(click(bad, 'Try again'));
|
||||
const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' }));
|
||||
expect(done.data.content).toContain('(off)');
|
||||
expect(messageText(done)).toContain('(off)');
|
||||
const expired = await f.run({ data: { name: 'settings' } });
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await f.run(click(expired, 'Change timezone'))).data.content).toContain('expired');
|
||||
expect(messageText((await f.run(click(expired, 'Change timezone'))))).toContain('expired');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ReminderService } from '../reminders/service';
|
||||
import type { ReminderEntry } from '../reminders/contracts';
|
||||
import type { Context, ControlData, Reply } from './types';
|
||||
import { fields } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const deliveryLabels: Record<string, string> = {
|
||||
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
|
||||
@@ -40,12 +40,13 @@ const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.lengt
|
||||
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
|
||||
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
|
||||
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
|
||||
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
|
||||
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
|
||||
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
|
||||
...pages(c, 'prefs.list', { page }, all.length),
|
||||
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
|
||||
if (command === 'settings') return screen(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
|
||||
const all = groups(c), page = listPage(data.page, all.length), visible = all.slice(page * listSize, (page + 1) * listSize);
|
||||
return componentMessage(c, [
|
||||
panel([display(`## Reminders · ${text(tz)}\n${status}\nReminders arrive by DM only when habits remain unfinished.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}${prefs.reminders.length}/20 time slots · ${pageLabel(page, all.length)}${all.length ? '' : '\nNo reminders. Add one to get started.'}`)]),
|
||||
...visible.map((g, i) => panel([section(`**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}`, button(c, 'Manage reminder', 'prefs.select', { ...state, index: page * listSize + i }))])),
|
||||
...pages(c, 'prefs.list', { page }, all.length, listSize),
|
||||
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }, 1), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
|
||||
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
|
||||
]);
|
||||
}
|
||||
@@ -84,12 +85,12 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
|
||||
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
|
||||
if (action === 'prefs.edit') return edit(c, data);
|
||||
if (action === 'prefs.select') {
|
||||
const selected = c.interaction.data?.values?.[0] ?? '';
|
||||
const selected = data.index === undefined ? c.interaction.data?.values?.[0] ?? '' : String(data.index);
|
||||
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
|
||||
const state = { ...data, index: Number(selected) }, g = group(c, state);
|
||||
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
|
||||
return screen(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
|
||||
}
|
||||
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
|
||||
if (action === 'prefs.remove') return screen(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
|
||||
try {
|
||||
if (action === 'prefs.timezoneSave') {
|
||||
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
|
||||
@@ -108,6 +109,6 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 422) throw error;
|
||||
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
|
||||
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
|
||||
return screen(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test';
|
||||
import { createAuth } from '../auth';
|
||||
import { createSharingRoutes } from '../sharing/routes';
|
||||
import { createLiveDiscord } from '../sharing/live-discord';
|
||||
import { discordFixture, control } from './test-fixture';
|
||||
import { discordFixture, control, messageText, habitControl, findHabitPage } from './test-fixture';
|
||||
import type { DeferredMessage } from './progress';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
let stop = () => {};
|
||||
@@ -23,24 +23,25 @@ function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean
|
||||
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
|
||||
}
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
|
||||
const id = label === 'Preview' && values?.[0] ? habitControl(reply, (await f.json(`/habits/${values[0]}`)).name, label) : control(reply, label);
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: id, values } }, guild);
|
||||
}
|
||||
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const panel = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(panel.data.content).toContain('Reading — 1/1');
|
||||
expect(panel.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
|
||||
expect(messageText(panel)).toContain('1/1 scheduled days complete');
|
||||
expect(panel.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
expect(await click(panel, 'Preview', guild, [habit.id])).toEqual({ type: 6 });
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const preview = await env.drain();
|
||||
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
|
||||
expect(preview.data.content).toContain('1/1');
|
||||
expect(messageText(preview)).toContain('1/1');
|
||||
await click(preview, 'Choose public destination', guild);
|
||||
const confirmation = await env.drain();
|
||||
expect(confirmation.data.content).toContain('habit-sharing');
|
||||
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(messageText(confirmation)).toContain('habit-sharing');
|
||||
expect(messageText(confirmation)).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
|
||||
await f.requestDiscord(i); await env.drain();
|
||||
@@ -62,28 +63,30 @@ test('pagination, cross-account controls, stale snapshot and uncertain delivery
|
||||
const env = setup({ fail: true });
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
let panel = await f.run({ data: { name: 'progress' } });
|
||||
expect(control(panel, 'Next')).toBeString();
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
|
||||
expect(stolen.data.content).toContain('belongs to another user');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
panel = await findHabitPage(f, panel, 'Read');
|
||||
const previewId = habitControl(panel, 'Read', 'Preview');
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: previewId, values: [h.id] } }, true, f.bob);
|
||||
expect(messageText(stolen)).toContain('belongs to another user');
|
||||
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirm = await env.drain();
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
|
||||
expect(messageText((await click(confirm, 'Share snapshot publicly')))).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
|
||||
expect(messageText((await click(preview, 'Choose public destination')))).toContain('changed');
|
||||
await click(panel, 'Preview', false, [h.id]); const newPreview = await env.drain();
|
||||
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
|
||||
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('could not confirm');
|
||||
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
|
||||
expect(messageText(result)).toContain('could not confirm');
|
||||
expect(messageText((await click(fresh, 'Share snapshot publicly')))).toContain('expired');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
});
|
||||
test('allowlist and disabled writes apply to command shares', async () => {
|
||||
const env = setup({ allowed: ['222222222222222222'] });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('approved community');
|
||||
expect(messageText(result)).toContain('approved community');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
|
||||
@@ -91,38 +94,65 @@ test('disabled writes, shared snapshot quotas, and stale live confirmations', as
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly'); const denied = await env.drain();
|
||||
expect(denied.data.content).toContain('disabled in development');
|
||||
expect(messageText(denied)).toContain('disabled in development');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
|
||||
const env = setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const first = await env.drain();
|
||||
await click(first, 'Share snapshot publicly'); await env.drain();
|
||||
await click(preview, 'Choose public destination'); const next = await env.drain();
|
||||
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
|
||||
expect(limited.data.content).toContain('Sharing limit reached');
|
||||
expect(messageText(limited)).toContain('Sharing limit reached');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
|
||||
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
|
||||
expect(messageText((await click(live, 'Start live image publicly')))).toContain('changed');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
expect(messageText((await click(preview, 'Choose public destination')))).toContain('expired');
|
||||
});
|
||||
|
||||
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain();
|
||||
await click(panel, 'Preview', false, [habit.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
|
||||
await click(confirmation, 'Share snapshot publicly');
|
||||
const delivery = env.drain();
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const result = await delivery;
|
||||
expect(result.data.content).toContain('changed');
|
||||
expect(messageText(result)).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
|
||||
for (const guild of [false, true]) test(`progress previews, theme changes, errors and back navigation edit the original (${guild ? 'server' : 'DM'})`, async () => {
|
||||
const env = setup();
|
||||
await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#123456' }, 201);
|
||||
const list = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(await click(list, 'Preview', guild)).toEqual({ type: 6 });
|
||||
let preview = await env.drain();
|
||||
expect(preview.data.flags).toBe(32768);
|
||||
expect(preview.data.content).toBeNull();
|
||||
expect(preview.data.components![0]!.accent_color).toBe(0x123456);
|
||||
expect(preview.data.components!.at(-1)).toMatchObject({ type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] });
|
||||
expect(await click(preview, 'Toggle theme', guild)).toEqual({ type: 6 });
|
||||
preview = await env.drain();
|
||||
const back = await click(preview, 'Back to private progress', guild);
|
||||
expect(back.type).toBe(7);
|
||||
expect(back.data.attachments).toEqual([]);
|
||||
expect(back.data.components.some((c: any) => c.type === 12)).toBe(false);
|
||||
expect(env.sends).toHaveLength(0);
|
||||
// Change the website after previewing; the error must still be a usable V2 update.
|
||||
const h = (await f.json('/habits')).habits[0];
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const failed = await click(preview, 'Choose public destination', guild);
|
||||
expect(failed.type).toBe(7);
|
||||
expect(failed.data.flags).toBe(32768);
|
||||
expect(messageText(failed)).toContain('Progress changed');
|
||||
expect((await click(failed, 'Back to private progress', guild)).type).toBe(7);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { liveCardData } from '../sharing/live-card-image';
|
||||
import type { createSharingRoutes } from '../sharing/routes';
|
||||
import type { createLiveDiscord } from '../sharing/live-discord';
|
||||
import type { Context, ControlData, Message, Reply } from './types';
|
||||
import { button, message, pages, row, select, text } from './ui';
|
||||
import { button, screen, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
export type ProgressOperations = {
|
||||
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
|
||||
@@ -28,25 +28,34 @@ function imageData(c: Context, data: ControlData) {
|
||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage {
|
||||
const message = reply.data as Message;
|
||||
if (image) message.components = [...(message.components ?? []), { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' }, description: 'Habit progress for the selected period. Exact totals are shown above.' }] }];
|
||||
return { message, image, imageDescription: image ? 'Habit progress for the selected period. Exact totals are shown in the message.' : undefined };
|
||||
}
|
||||
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
||||
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
|
||||
c.defer(job);
|
||||
return { type: 5, data: { flags: 64 } };
|
||||
c.defer(async () => {
|
||||
try { return await job(); }
|
||||
catch (error) {
|
||||
return output(screen(c, error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', [row(button(c, 'Back to private progress', 'progress'))]));
|
||||
}
|
||||
});
|
||||
return c.interaction.message && [3, 5].includes(c.interaction.type) ? { type: 6 } : { type: 5, data: { flags: c.interaction.guild_id ? 64 : 0 } };
|
||||
}
|
||||
export function progress(c: Context, data: ControlData = {}): Reply {
|
||||
const habits = c.service.list(), page = data.page ?? 0;
|
||||
const visible = habits.slice(page * 6, page * 6 + 6);
|
||||
const habits = c.service.list(), page = listPage(data.page, habits.length);
|
||||
const result = imageData(c, {});
|
||||
const state = c.progress?.live.state(c.service.user.id);
|
||||
return message(c, `**Progress · ${result.from} to ${result.to}**\n${text(c.service.user.timezone)} · Live image: ${state?.status ?? 'unavailable'}\n\n${visible.map(h => {
|
||||
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
|
||||
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`;
|
||||
}).join('\n') || 'No active habits. Create a habit on the website or with /habits.'}\n\nPreviews are private. A public share requires confirmation of the configured destination.`, [
|
||||
...(visible.length ? [select(c, 'Preview a habit', 'progress.preview', {}, visible.map(h => ({ id: h.id, name: h.name })))] : []),
|
||||
...pages(c, 'progress', data, habits.length, 6),
|
||||
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')),
|
||||
], false);
|
||||
return componentMessage(c, [
|
||||
panel([display(`## Progress\n${result.from} to ${result.to} · ${text(c.service.user.timezone)}\nLive image: ${state?.status ?? 'unavailable'} · ${pageLabel(page, habits.length)}\nPreviews are private. Public sharing requires confirmation.${habits.length ? '' : '\nNo active habits. Create one with /habits.'}`)]),
|
||||
...habits.slice(page * listSize, (page + 1) * listSize).map(h => {
|
||||
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
|
||||
return panel([section(`**${text(h.name)}**\n${stats.completed}/${stats.scheduled} scheduled days complete`, button(c, 'Preview', 'progress.preview', { habitId: h.id, page }))], c.service.settings(h.id).mainColor);
|
||||
}),
|
||||
...pages(c, 'progress', { page }, habits.length, listSize),
|
||||
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress', { page })),
|
||||
]);
|
||||
}
|
||||
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'progress') return progress(c, data);
|
||||
@@ -54,15 +63,15 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const ops = c.progress;
|
||||
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
|
||||
if (action === 'progress.preview') {
|
||||
const habitId = c.interaction.data?.values?.[0] ?? data.habitId;
|
||||
const habitId = data.habitId ?? c.interaction.data?.values?.[0];
|
||||
c.service.current(habitId!);
|
||||
const selection = { habitId, theme: data.theme ?? 'light' as const };
|
||||
const selection = { habitId, page: data.page, theme: data.theme ?? 'light' as const };
|
||||
return defer(c, async () => {
|
||||
const content = imageData(c, selection);
|
||||
const image = await render(c, selection, content);
|
||||
const confirmed = { ...selection, expected: fingerprint(content) };
|
||||
const stats = content.habits[0]!;
|
||||
return output(message(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }))], false), image);
|
||||
return output(screen(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }), button(c, 'Back to private progress', 'progress', { page: data.page }))], true, c.service.settings(habitId!).mainColor), image);
|
||||
});
|
||||
}
|
||||
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
|
||||
@@ -71,7 +80,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const destination = await ops.sharing.destination(c.service.user);
|
||||
const state = ops.live.state(c.service.user.id);
|
||||
const selection = { ...data, expected: action === 'progress.confirm' ? data.expected : fingerprint(liveSnapshot()), deliveryId: crypto.randomUUID(), mode: destination.channelUrl };
|
||||
return output(message(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))], false));
|
||||
return output(screen(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))]));
|
||||
});
|
||||
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
|
||||
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
|
||||
@@ -90,10 +99,10 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const image = await render(c, data, source);
|
||||
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
|
||||
return output(screen(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [row(button(c, 'Back to private progress', 'progress'))]));
|
||||
}
|
||||
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
|
||||
return output(message(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))], false));
|
||||
return output(screen(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { manageHabits, manageTasks, managementAction } from './management';
|
||||
import { history, historyAction } from './history';
|
||||
import { preferences, preferencesAction } from './preferences';
|
||||
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
|
||||
import { message } from './ui';
|
||||
import { message, screen, button, row } from './ui';
|
||||
import { help } from './help';
|
||||
import { commandGraphicAction } from './command-graphics';
|
||||
|
||||
@@ -24,12 +24,13 @@ export type DeferredDispatch = {
|
||||
schedule?: (job: () => Promise<void>) => void;
|
||||
};
|
||||
/** Edit the deferred original exactly once. Never retry an uncertain network result. */
|
||||
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
|
||||
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage, request: (url: string, init: RequestInit) => Promise<Response> = fetch) {
|
||||
const body = new FormData();
|
||||
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
|
||||
const native = !!(result.message.flags && (result.message.flags & 32768));
|
||||
const payload = { ...result.message, flags: native ? 32768 : undefined, ...(native ? { content: null, embeds: [] } : {}), allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
|
||||
body.set('payload_json', JSON.stringify(payload));
|
||||
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
|
||||
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
const response = await request(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error('Deferred Discord response was rejected.');
|
||||
}
|
||||
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) {
|
||||
@@ -67,6 +68,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
const controls = new ControlStore(db, userId, now()); controls.prune();
|
||||
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
|
||||
let result: Reply;
|
||||
let ownedAction: string | undefined;
|
||||
try {
|
||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
||||
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
|
||||
@@ -77,11 +79,14 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
|
||||
else if ([3, 5].includes(i.type)) {
|
||||
const state = controls.read(i.data?.custom_id ?? '');
|
||||
ownedAction = state.action;
|
||||
result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} else result = message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||
result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);
|
||||
const reason = error instanceof ApiError ? error.message : 'Check the entered values and try again.';
|
||||
const home = ownedAction?.startsWith('manage:') ? ['Back to habits', 'manage:list'] : ownedAction?.startsWith('prefs.') ? ['Reminders', 'prefs.list'] : ownedAction?.startsWith('progress') ? ['Back to private progress', 'progress'] : ownedAction?.startsWith('history') ? ['History', 'history'] : undefined;
|
||||
result = home ? screen(ctx, reason, [row(button(ctx, home[0]!, home[1]!))]) : message(ctx, reason, [], false);
|
||||
}
|
||||
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
|
||||
return result;
|
||||
|
||||
@@ -47,3 +47,17 @@ export function habitControl(reply: any, name: string, label = 'Details'): strin
|
||||
if (!item) throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
|
||||
return control({ data: { components: [item] } }, label);
|
||||
}
|
||||
|
||||
export async function findHabitPage(f: ReturnType<typeof discordFixture>, reply: any, name: string, guild = false) {
|
||||
for (let page = 0; page < 100; page++) {
|
||||
if (messageText(reply).includes(`**${name}**`)) return reply;
|
||||
if (!components(reply.data.components).some(c => c.label === 'Next')) break;
|
||||
reply = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, 'Next') } }, guild);
|
||||
}
|
||||
throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
|
||||
}
|
||||
export function taskControl(reply: any, name: string): string {
|
||||
const item = components(reply.data.components).find(c => c.type === 9 && [`○ ${name}`, `✓ ${name}`].includes(c.components[0].content));
|
||||
if (!item) throw new Error(`Missing task ${name}: ${JSON.stringify(reply)}`);
|
||||
return item.accessory.custom_id;
|
||||
}
|
||||
|
||||
@@ -28,3 +28,11 @@ export function componentMessage(c: Context, components: DiscordComponent[], upd
|
||||
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: [] } : {}) } };
|
||||
}
|
||||
|
||||
/** Shared command screen using the same native panels as /today. */
|
||||
export function screen(c: Context, content: string, controls: DiscordComponent[] = [], update = true, color?: string): Reply {
|
||||
return componentMessage(c, [panel([display(content), ...controls], color)], update);
|
||||
}
|
||||
export const listSize = 6;
|
||||
export const listPage = (page: number | undefined, total: number, size = listSize) => Math.max(0, Math.min(Math.trunc(page ?? 0), Math.max(0, Math.ceil(total / size) - 1)));
|
||||
export const pageLabel = (page: number, total: number, size = listSize) => `Page ${page + 1} of ${Math.max(1, Math.ceil(total / size))}`;
|
||||
|
||||
Reference in New Issue
Block a user