Extend native Discord command screens and same-message navigation

This commit is contained in:
syntaxbullet
2026-10-26 20:16:52 +01:00
parent 17b3714861
commit 5c7ac488b5
16 changed files with 449 additions and 202 deletions

View File

@@ -36,8 +36,8 @@ no signed interaction endpoint configured. Global command registration and the
Discord interaction endpoint must be configured separately before production use. Discord interaction endpoint must be configured separately before production use.
## Preferences ## Preferences
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus `preferences.ts` implements global `/settings` and `/reminders`. Reminder panels
paginate ten named schedules at a time; entries with matching name and schedule 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 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 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 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 No new environment variables, migrations, outbound requests, or remote command
registration are needed by this module. Global command registration remains an registration are needed by this module. Global command registration remains an
integration step. Successful mutations invoke the supplied live-card dirty hook. 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 previews, explicit destination confirmation for snapshots, and live-image
start/status/pause/resume. API composition injects the same `sharing.send` and start/status/pause/resume. API composition injects the same `sharing.send` and
`live.start/pause/state` operations used by website routes. Keep these shared `live.start/pause/state` operations used by website routes. Keep these shared
instances when reconciling dispatch; do not construct a second live worker. instances when reconciling dispatch; do not construct a second live worker.
Rendering and network actions return ephemeral type-5 acknowledgements and edit Progress rendering and network actions acknowledge existing message interactions
the original interaction response once. `DeferredDispatch.complete` and 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, `schedule` are injectable for tests. Interaction tokens live only in memory,
never receipts or controls. A process interruption after acknowledgement can never receipts or controls. A process interruption after acknowledgement can
leave the private response unfinished; restarting never replays the public send. 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 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 **View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
conflicts, and delivery status. Schedule cards retain disabled/unavailable states. conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
Management forms and `/help` remain text-based because their controls already Management uses native component panels and forms. `/help` remains text-based.
communicate the relevant choices.
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
request a fresh owned snapshot. Regular check-ins keep their existing immediate request a fresh owned snapshot. Regular check-ins use immediate
text responses. Graphic actions acknowledge before rendering and attach a PNG to 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. 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 Each image has a text equivalent and attachment description. A render failure
retains the text and navigation. No new command registration or configuration is 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, message (including amount-modal submissions). Updates clear legacy content,
embeds, and attachments when upgrading an older message to V2. Server command embeds, and attachments when upgrading an older message to V2. Server command
replies remain ephemeral; reminder buttons open a separate private flow. Optional replies remain ephemeral; reminder buttons open a separate private flow. Optional
PNG cards still open as separate snapshots. Historical editors keep their dated PNG cards still open as separate snapshots. Historical editors reuse the same component check-in screens while retaining
requirements and existing interface. their dated requirements.
The shared HabitService, owner-bound controls, stale snapshot checks, replay The shared HabitService, owner-bound controls, stale snapshot checks, replay
receipts, and local-midnight protections remain authoritative. No schema change, receipts, and local-midnight protections remain authoritative. No schema change,
new API, or command registration is required. Native Discord mobile/desktop layout new API, or command registration is required. Native Discord mobile/desktop layout
and real modal return behavior should be checked manually after deployment. 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.

View File

@@ -48,7 +48,8 @@ test('graphics retain ownership, expiry, pagination and selected historical requ
f.setDiscordTime('2026-09-05T12:00:00Z'); f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 }); await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
const history = await f.run({ data: { name: 'history' } }, true); 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 day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
const id = control(day, 'View check-in card'); const id = control(day, 'View check-in card');
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another'); expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');

View 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();
});

View File

@@ -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 }))); 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'); 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 d = c.service.day(data.habitId!, data.date!);
const page = clampPage(data.page, d.tasks.length, taskSize); const size = data.mode === 'history' ? 6 : taskSize;
const state = { ...data, mode: undefined, page, expected: snapshot(d) }; const page = clampPage(data.page, d.tasks.length, size);
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 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.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 === '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 === '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') { 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))); 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 > taskSize) components.push(display(`Page ${page + 1} of ${Math.ceil(d.tasks.length / taskSize)}`)); 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, taskSize)); 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 }))]); 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 { export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today; const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.'); if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
if (data.today) return todayDetail(c, { ...data, date }); if (data.today || data.mode === 'history') return checkInDetail(c, { ...data, date });
const d = c.service.day(data.habitId!, date); const d = c.service.day(data.habitId!, date);
data = { ...data, date, page: pageFor(data.page, d.tasks.length) }; data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
const state: ControlData = { ...data, expected: snapshot(d) }; const state: ControlData = { ...data, expected: snapshot(d) };
@@ -103,7 +104,7 @@ export function dailyAction(c: Context, name: string, data: ControlData): Reply
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
const invalid = error instanceof ZodError; const invalid = error instanceof ZodError;
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data); const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
if (data.today) return componentMessage(c, [panel([display(invalid ? 'Enter a whole number from 0 to 1,000,000,000. Nothing was saved.' : error.message)]), row(recover, button(c, 'Today', 'today'))]); 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, return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
[row(recover, button(c, 'Today', 'today'))], false); [row(recover, button(c, 'Today', 'today'))], false);
} }

View File

@@ -1,16 +1,18 @@
import { afterEach, describe, expect, test } from 'bun:test'; 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'; import { commands } from './commands';
let f: ReturnType<typeof discordFixture>; let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close()); 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) { 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); 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) { async function open(habitId: string, date: string, guild = false) {
const list = await f.run({ data: { name: 'history' } }, guild); const h = await f.json(`/habits/${habitId}`);
const form = await click(list, 'Choose a habit', guild, [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); expect(form.type).toBe(9);
return f.run(submit(form.data.custom_id, { date }), guild); 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); return f.run(submit(form.data.custom_id, { date }), guild);
} }
function readOnly(reply: any) { 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); 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 }); await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const list = await f.run({ data: { name: 'history' } }, guild); const list = await f.run({ data: { name: 'history' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined); expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(items(list)[0].options[0].description).toContain('Archived'); expect(messageText(list)).toContain('Archived');
let d = await open(h.id, '2026-09-04', guild); 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); const amount = await click(d, 'Enter amount', guild);
d = await f.run(submit(amount.data.custom_id, { amount: '4' }), 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(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4); expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4);
d = await click(d, 'Next date', guild); 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); 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); d = await click(d, 'Mark complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true); expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true);
d = await click(d, 'Mark incomplete', guild); d = await click(d, 'Mark incomplete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false); expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false);
d = await click(d, 'Next date', guild); d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Original task'); expect(messageText(d)).toContain('Original task');
expect(d.data.content).not.toContain('New task'); expect(messageText(d)).not.toContain('New task');
d = await click(d, 'Choose a task to change its completion', guild, [task.id]); d = await click(d, 'Complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true); expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true);
d = await click(d, 'Next date', guild); d = await click(d, 'Next date', guild);
readOnly(d); readOnly(d);
d = await click(d, 'Next date', guild); d = await click(d, 'Next date', guild);
readOnly(d); 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(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true); expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
}); });
@@ -79,21 +81,21 @@ describe('/history', () => {
f = discordFixture(); f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201); 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'); 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']) { for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) {
d = await changeDate(d, date); 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'); d = await changeDate(d, '2024-02-29');
readOnly(d); readOnly(d);
expect(d.data.content).toContain('not tracked yet'); expect(messageText(d)).toContain('not tracked yet');
d = await changeDate(d, '2026-09-05'); 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); expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
f.setDiscordTime('2026-09-06T12:00:00Z'); f.setDiscordTime('2026-09-06T12:00:00Z');
d = await open(h.id, '2026-09-05'); d = await open(h.id, '2026-09-05');
readOnly(d); readOnly(d);
expect(d.data.content).toContain('Friday'); expect(messageText(d)).toContain('Friday');
d = await changeDate(d, '1970-01-01'); d = await changeDate(d, '1970-01-01');
expect(items(d).some(i => i.label === 'Previous date')).toBe(false); expect(items(d).some(i => i.label === 'Previous date')).toBe(false);
d = await changeDate(d, '9998-12-31'); 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); 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); 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' } }); 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'); list = await click(list, 'Next');
expect(items(list)[0].options).toHaveLength(3); expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2'); expect(messageText((await click(list, 'Previous')))).toContain('Page 1 of 3');
const form = await click(list, 'Choose a habit', false, [h.id]); let d = await open(h.id, '2026-09-04');
let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }));
d = await click(d, 'Next'); d = await click(d, 'Next');
expect(items(d)[0].options[0].label).toBe('○ Task 10'); expect(messageText(d)).toContain('○ Task 6');
expect(d.data.components.length).toBeLessThanOrEqual(5); expect(items(d).length).toBeLessThanOrEqual(40);
d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]); d = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: taskControl(d, 'Task 10') } });
expect(d.data.content).toContain('✓ Task 10'); expect(messageText(d)).toContain('✓ Task 10');
d = await click(d, 'Refresh'); 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(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true); 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); const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201);
await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b'); await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b');
const a = await f.run({ data: { name: 'history' } }, guild); const a = await f.run({ data: { name: 'history' } }, guild);
const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob); const rejected = await click(a, 'Open history', guild, [h.id], f.bob);
expect(rejected.data.content).toContain('belongs to another user'); expect(messageText(rejected)).toContain('belongs to another user');
expect(rejected.data.flags).toBe(guild ? 64 : undefined); expect(rejected.data.flags).toBe(guild ? 64 : undefined);
const b = await f.run({ data: { name: 'history' } }, guild, f.bob); const b = await f.run({ data: { name: 'history' } }, guild, f.bob);
expect(JSON.stringify(b)).not.toContain('Secret'); 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); 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((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
expect(f.changed).toEqual([]); expect(f.changed).toEqual([]);
}); });
@@ -147,20 +149,20 @@ describe('/history', () => {
let d = await open(h.id, '2026-09-04'); let d = await open(h.id, '2026-09-04');
const oldAmount = await click(d, 'Enter amount'); const oldAmount = await click(d, 'Enter amount');
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 }); 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'); 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 interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } });
const saved = await (await f.requestDiscord(interaction)).json(); const saved = await (await f.requestDiscord(interaction)).json();
f.restart(); f.restart();
expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved); 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`)).value).toBe(9);
expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2); expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2);
expect(f.changed).toEqual(['alice']); expect(f.changed).toEqual(['alice']);
d = await open(h.id, '2026-09-04'); d = await open(h.id, '2026-09-04');
f.setDiscordTime('2026-09-05T12:16:00Z'); 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 () => { 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); const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201);
f.setDiscordTime('2026-09-04T22:05:00Z'); f.setDiscordTime('2026-09-04T22:05:00Z');
const list = await f.run({ data: { name: 'history' } }); 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'); expect(form.data.components[0].components[0].value).toBe('2026-09-05');
await f.json('/me', 'PATCH', { timezone: 'America/New_York' }); await f.json('/me', 'PATCH', { timezone: 'America/New_York' });
const d = await open(h.id, '2026-09-04'); 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'); 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 () => { test('keeps earlier schedule requirements editable after a schedule change', async () => {
f = discordFixture(); f = discordFixture();
const empty = await f.run({ data: { name: 'history' } }); const empty = await f.run({ data: { name: 'history' } });
expect(empty.data.content).toContain('No habits yet'); expect(messageText(empty)).toContain('No habits yet');
expect(items(empty)).toEqual([]); expect(items(empty).filter(c => c.type === 2)).toEqual([]);
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201); const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z'); f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } }); await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
let d = await open(h.id, '2026-09-04'); 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'); d = await click(d, 'Mark complete');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true); expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true);
d = await click(d, 'Next date'); d = await click(d, 'Next date');
readOnly(d); readOnly(d);
expect(d.data.content).toContain('Monday'); expect(messageText(d)).toContain('Monday');
d = await changeDate(d, '2026-09-07'); 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(items(d).some(i => i.label === 'Mark complete')).toBe(false);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false); expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false);
}); });

View File

@@ -2,7 +2,7 @@ import { addDays } from '../habits/calendar';
import { dateSchema, type Schedule } from '../habits/contracts'; import { dateSchema, type Schedule } from '../habits/contracts';
import { dailyAction, detail } from './daily'; import { dailyAction, detail } from './daily';
import { fields, type Context, type ControlData, type Message, type Reply } from './types'; 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']; const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function scheduleLabel(schedule: Schedule): string { function scheduleLabel(schedule: Schedule): string {
@@ -14,11 +14,15 @@ function scheduleLabel(schedule: Schedule): string {
export function history(c: Context, data: ControlData = {}): Reply { export function history(c: Context, data: ControlData = {}): Reply {
const all = c.service.list(true); const all = c.service.list(true);
const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)); const page = listPage(data.page, all.length);
const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false }; const state: ControlData = { date: data.date ?? c.service.today, mode: 'history', today: false };
const visible = all.slice(page * 10, page * 10 + 10); return componentMessage(c, [
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.'}`, 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.'}`)]),
[...(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)]); ...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 { 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.' : ''; : !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : '';
const body = reply.data as Message; const body = reply.data as Message;
const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : ''; 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 { 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.form') return dateForm(c, data);
if (action === 'history.date') { if (action === 'history.date') {
const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim()); 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 }; data = { ...data, date: parsed.data, page: 0 };
action = 'history.detail'; action = 'history.detail';
} }

View File

@@ -1,5 +1,5 @@
import { afterEach, expect, test } from 'bun:test'; 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>; let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close()); afterEach(() => f?.close());
async function click(reply: any, label: string, guild = false, values?: string[]) { 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) { async function list(id: string, guild = false) {
const home = await f.run({ data: { name: 'tasks' } }, guild); 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]); 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]); } 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); 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 }); 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); 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); expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
await click(confirm, 'Confirm task deletion', guild); await click(confirm, 'Confirm task deletion', guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0); 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' }); 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']); let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
detail = await click(menu, 'Select weekdays', false, ['2']); 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'); menu = await click(detail, 'Change task schedule');
detail = await click(menu, 'Recurrence', false, ['daily']); detail = await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' }); expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
form = await click(detail, 'Rename task'); 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'); 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 () => { 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 id = h.tasks[0].id;
const rename = await click(await task(h.id, id), 'Rename task'); const rename = await click(await task(h.id, id), 'Rename task');
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' }); 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'); f.setDiscordTime('2026-09-05T12:00:00Z');
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion'); 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}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0); 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 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'); 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); 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' } }); 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 () => { test('add forms are replay safe after restart and task pagination stays within limits', async () => {
f = discordFixture(); const h = await setup(); 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); expect(f.changed).toHaveLength(1);
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201); 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); 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'); 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(); expect(control(next, 'Previous')).toBeTruthy();
}); });
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => { 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(); const h = await setup();
let add = await click(await list(h.id), 'Add task'); let add = await click(await list(h.id), 'Add task');
f.setDiscordTime('2026-09-04T22:01:00Z'); 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'); add = await click(await list(h.id), 'Add task');
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' }); 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 c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
const home = await f.run({ data: { name: 'habits' } }); 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'); const form = await click(detail, 'Edit details');
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 }); 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 () => { test('a complete checklist routine can be created entirely in Discord', async () => {
f = discordFixture(); 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 tasks = await click(habit, 'Manage tasks', true);
const add = await click(tasks, 'Add task', true); const add = await click(tasks, 'Add task', true);
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), 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]; const website = (await f.json('/habits')).habits[0];
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow'); expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
const settings = await f.json(`/habits/${website.id}/calendar-settings`); 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 other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
const taskMenu = await list(website.id); const taskMenu = await list(website.id);
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].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');
}); });

View File

@@ -1,18 +1,22 @@
import { afterEach, expect, test } from 'bun:test'; 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>; let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close()); afterEach(() => f?.close());
async function click(reply: any, label: string, guild = false, values?: string[]) { 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); 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) { async function create(method: string, guild = false) {
const list = await f.run({ data: { name: 'habits' } }, guild); 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 methodMenu = await click(list, 'Create habit', guild);
const form = await click(methodMenu, 'Tracking method', guild, [method]); 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); 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] }; 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 () => { 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); expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild); 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); 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); 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((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
expect(f.changed.length).toBe(7); expect(f.changed.length).toBe(7);
}); });
@@ -54,7 +58,7 @@ test('all recurrences use validated friendly controls', async () => {
await click(menu, 'Recurrence', false, ['daily']); await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: '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']); 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' }); expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
}); });
test('method conversion warns, resets current progress and preserves past dated requirements', async () => { 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'); f.setDiscordTime('2026-09-05T12:00:00Z');
const menu = await click(await open(habit.id), 'Change method'); const menu = await click(await open(habit.id), 'Change method');
const confirmation = await click(menu, 'New tracking method', false, ['count']); 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'); const form = await click(confirmation, 'Continue conversion');
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' })); 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`); 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'); f = discordFixture(); const { result, habit } = await create('manual');
const form = await click(result, 'Edit details'); const form = await click(result, 'Edit details');
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' }); 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 fresh = await click(await open(habit.id), 'Edit details');
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' })); const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
await f.requestDiscord(payload); const changes = f.changed.length; await f.requestDiscord(payload); const changes = f.changed.length;
f.restart(); await f.requestDiscord(payload); f.restart(); await f.requestDiscord(payload);
expect(f.changed.length).toBe(changes); expect(f.changed.length).toBe(changes);
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired'); expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))))).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: 'Attack', color: '#123456' }), true, f.bob)))).toContain('belongs to another');
expect((await f.json('/habits')).habits[0].name).toBe('Final'); expect((await f.json('/habits')).habits[0].name).toBe('Final');
}); });
test('pagination keeps Discord component and option limits', async () => { 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); 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' } }); 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'); 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(); expect(control(next, 'Previous')).toBeTruthy();
}); });

View File

@@ -1,7 +1,7 @@
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts'; import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { ApiError } from '../habits/service'; import { ApiError } from '../habits/service';
import { fields, type Context, type ControlData, type Reply } from './types'; 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 prefix = 'manage:';
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }]; 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 { export function manageHabits(c: Context, d: ControlData = {}): Reply {
const archived = d.mode === 'archived'; const archived = d.mode === 'archived';
const habits = c.service.list(true).filter(h => h.archived === 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 }; 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.'}`, [ return componentMessage(c, [
...(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) })))] : []), 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.'}`)]),
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })), ...habits.slice(page * listSize, (page + 1) * listSize).map(h => panel([
...pages(c, prefix + 'list', state, habits.length), 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 { function detail(c: Context, id: string): Reply {
const h = c.service.current(id), d = snapshot(c, id); 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)), 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 === '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))] : []), ...(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 { function detailsForm(c: Context, d: ControlData, creating = false): Reply {
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method; 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 { function schedulePicker(c: Context, d: ControlData): Reply {
check(c, d); 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 { function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
check(c, d); 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 === '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 === '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 === '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 === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
if (a === 'save-create') { if (a === 'save-create') {
const f = fields(c.interaction); 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!); 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 === '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') { if (a === 'method-confirm') {
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id)); 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') { if (a === 'convert') {
check(c, d); check(c, d);
@@ -100,7 +103,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
} }
if (a === 'confirm') { if (a === 'confirm') {
const h = check(c, d); 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 === '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); if (a === 'schedule') return schedulePicker(c, d);
@@ -110,7 +113,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
if (kind === 'weekdays') { if (kind === 'weekdays') {
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name }))); 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 }); 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; 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.'); 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 { export function manageTasks(c: Context, d: ControlData = {}): Reply {
const habits = c.service.list().filter(h => h.method === 'tasks'); 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))); 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 })))] : []), ...(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')), row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
...pages(c, prefix + 'tasks-home', { page }, habits.length), ...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.'); 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 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 }; 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) })))] : []), ...(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')), 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), ...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.'); 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 state = { ...snapshot(c, h.id), taskId: task.id };
const warning = h.warnings.find(w => w.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, '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)), 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') { if (a === 'task-delete') {
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId); 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.'); 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 }); } if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
} }

View File

@@ -1,14 +1,14 @@
import { afterEach, expect, test } from 'bun:test'; import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { reminderDeliveries, users } from '../db/schema'; 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'; import { commands } from './commands';
let f: ReturnType<typeof discordFixture>; let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close()); afterEach(() => f?.close());
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } }); 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: '' }; 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') { 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', () => { 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]); 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); f = discordFixture(true);
await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' }); await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' });
let list = await f.run({ data: { name: 'reminders' } }, guild); 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 form = await f.run(click(list, 'Add reminder'), guild);
const interaction = f.payload(submit(form.data.custom_id, draft), guild); const interaction = f.payload(submit(form.data.custom_id, draft), guild);
list = await (await f.requestDiscord(interaction)).json(); 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); list = await f.run(click(confirmation, 'Confirm removal'), guild);
expect((await f.json('/reminders')).reminders).toEqual([]); expect((await f.json('/reminders')).reminders).toEqual([]);
expect((await f.json('/reminders')).enabled).toBe(false); 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 settings = await f.run({ data: { name: 'settings' } }, guild);
const tz = await f.run(click(settings, 'Change timezone'), guild); const tz = await f.run(click(settings, 'Change timezone'), guild);
await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), 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')); 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' }]) { 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)); 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')); form = await f.run(click(failed, 'Try again'));
expect(form.type).toBe(9); expect(form.type).toBe(9);
expect(form.data.components[0].components[0].value).toBe(draft.name); expect(form.data.components[0].components[0].value).toBe(draft.name);
} }
const saved = await f.run(submit(form.data.custom_id, draft)); 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')); 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); expect((await f.json('/reminders')).enabled).toBe(false);
const settings = await f.run({ data: { name: 'settings' } }); const settings = await f.run({ data: { name: 'settings' } });
const timezone = await f.run(click(settings, 'Change timezone')); const timezone = await f.run(click(settings, 'Change timezone'));
const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' })); 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')); const retry = await f.run(click(invalid, 'Try again'));
await f.run(submit(retry.data.custom_id, { timezone: 'UTC' })); await f.run(submit(retry.data.custom_id, { timezone: 'UTC' }));
expect((await f.json('/me')).timezone).toBe('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' }; const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' };
await f.json('/reminders', 'PUT', prefs); await f.json('/reminders', 'PUT', prefs);
const blocked = await f.run(submit(form.data.custom_id, draft)); 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 newer = await f.run({ data: { name: 'reminders' } });
const newerForm = await f.run(click(newer, 'Add reminder')); const newerForm = await f.run(click(newer, 'Add reminder'));
await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' }); await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' });
await f.json('/reminders', 'PUT', prefs); 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 settings = await f.run({ data: { name: 'settings' } });
const tz = await f.run(click(settings, 'Change timezone')); const tz = await f.run(click(settings, 'Change timezone'));
await f.json('/me', 'PATCH', { timezone: 'UTC' }); await f.json('/me', 'PATCH', { timezone: 'UTC' });
await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' }); 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'); 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 form = await f.run(click(list, 'Add reminder'));
const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob); const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob);
expect(blocked.data.flags).toBe(64); 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); expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1);
const id = '555555555555555555'; const id = '555555555555555555';
const settings = await f.run({ data: { name: 'settings' } }, false, id); 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'); 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' }); 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(); 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' } }); 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')); 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 form = await f.run(click(next, 'Add reminder'));
const fail = await f.run(submit(form.data.custom_id, draft)); 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); expect((await f.json('/reminders')).reminders).toHaveLength(20);
const quiet = await f.run(click(list, 'Quiet hours')); 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 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 retry = await f.run(click(bad, 'Try again'));
const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' })); 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' } }); const expired = await f.run({ data: { name: 'settings' } });
f.setDiscordTime('2026-09-04T12:16:00Z'); 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');
}); });

View File

@@ -6,7 +6,7 @@ import { ReminderService } from '../reminders/service';
import type { ReminderEntry } from '../reminders/contracts'; import type { ReminderEntry } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types'; import type { Context, ControlData, Reply } from './types';
import { fields } from './types'; import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui'; import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
const deliveryLabels: Record<string, string> = { const deliveryLabels: Record<string, string> = {
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.', 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 { export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data); 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)' : ''}`; 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'))]); 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 = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10); const all = groups(c), page = listPage(data.page, all.length), visible = all.slice(page * listSize, (page + 1) * listSize);
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`, [ return componentMessage(c, [
...(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)}` })))] : []), 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.'}`)]),
...pages(c, 'prefs.list', { page }, all.length), ...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 }))])),
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)), ...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')), 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.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.edit') return edit(c, data);
if (action === 'prefs.select') { 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.'); if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
const state = { ...data, index: Number(selected) }, g = group(c, state); 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 { try {
if (action === 'prefs.timezoneSave') { if (action === 'prefs.timezoneSave') {
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() }); 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) { } catch (error) {
if (!(error instanceof ApiError) || error.status !== 422) throw 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]; 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'))]);
} }
} }

View File

@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test';
import { createAuth } from '../auth'; import { createAuth } from '../auth';
import { createSharingRoutes } from '../sharing/routes'; import { createSharingRoutes } from '../sharing/routes';
import { createLiveDiscord } from '../sharing/live-discord'; 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'; import type { DeferredMessage } from './progress';
let f: ReturnType<typeof discordFixture>; let f: ReturnType<typeof discordFixture>;
let stop = () => {}; 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 }; } }; 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[]) { 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 () => { for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
const env = setup(); const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); 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 }); await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const panel = await f.run({ data: { name: 'progress' } }, guild); const panel = await f.run({ data: { name: 'progress' } }, guild);
expect(panel.data.content).toContain('Reading — 1/1'); expect(messageText(panel)).toContain('1/1 scheduled days complete');
expect(panel.data.flags).toBe(guild ? 64 : undefined); expect(panel.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } }); expect(await click(panel, 'Preview', guild, [habit.id])).toEqual({ type: 6 });
expect(env.sends).toHaveLength(0); expect(env.sends).toHaveLength(0);
const preview = await env.drain(); 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(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); await click(preview, 'Choose public destination', guild);
const confirmation = await env.drain(); const confirmation = await env.drain();
expect(confirmation.data.content).toContain('habit-sharing'); expect(messageText(confirmation)).toContain('habit-sharing');
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555'); expect(messageText(confirmation)).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
expect(env.sends).toHaveLength(0); expect(env.sends).toHaveLength(0);
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild); const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
await f.requestDiscord(i); await env.drain(); 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 env = setup({ fail: true });
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); 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); 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(); 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); panel = await findHabitPage(f, panel, 'Read');
expect(stolen.data.content).toContain('belongs to another user'); const previewId = habitControl(panel, 'Read', 'Preview');
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain(); 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 click(preview, 'Choose public destination'); const confirm = await env.drain();
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true }); 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(env.sends).toHaveLength(0);
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed'); expect(messageText((await click(preview, 'Choose public destination')))).toContain('changed');
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain(); 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(newPreview, 'Choose public destination'); const fresh = await env.drain();
await click(fresh, 'Share snapshot publicly'); const result = await env.drain(); await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
expect(result.data.content).toContain('could not confirm'); expect(messageText(result)).toContain('could not confirm');
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired'); expect(messageText((await click(fresh, 'Share snapshot publicly')))).toContain('expired');
expect(env.sends).toHaveLength(1); expect(env.sends).toHaveLength(1);
}); });
test('allowlist and disabled writes apply to command shares', async () => { test('allowlist and disabled writes apply to command shares', async () => {
const env = setup({ allowed: ['222222222222222222'] }); const env = setup({ allowed: ['222222222222222222'] });
const panel = await f.run({ data: { name: 'progress' } }); const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const result = await env.drain(); 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); expect(env.sends).toHaveLength(0);
}); });
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => { 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' } }); const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const live = await env.drain(); await click(panel, 'Live image controls'); const live = await env.drain();
await click(live, 'Start live image publicly'); const denied = 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); expect(env.sends).toHaveLength(0);
}); });
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => { test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
const env = setup(); const env = setup();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } }); 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(preview, 'Choose public destination'); const first = await env.drain();
await click(first, 'Share snapshot publicly'); 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(preview, 'Choose public destination'); const next = await env.drain();
await click(next, 'Share snapshot publicly'); const limited = 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); expect(env.sends).toHaveLength(1);
await click(panel, 'Live image controls'); const live = await env.drain(); await click(panel, 'Live image controls'); const live = await env.drain();
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201); 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); expect(env.sends).toHaveLength(1);
f.setDiscordTime('2026-09-04T12:16:00Z'); 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 () => { test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
const env = setup(); const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } }); 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(preview, 'Choose public destination'); const confirmation = await env.drain();
await click(confirmation, 'Share snapshot publicly'); await click(confirmation, 'Share snapshot publicly');
const delivery = env.drain(); const delivery = env.drain();
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true }); await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const result = await delivery; const result = await delivery;
expect(result.data.content).toContain('changed'); expect(messageText(result)).toContain('changed');
expect(env.sends).toHaveLength(0); 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);
});

View File

@@ -5,7 +5,7 @@ import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes'; import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord'; import type { createLiveDiscord } from '../sharing/live-discord';
import type { Context, ControlData, Message, Reply } from './types'; 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 = { export type ProgressOperations = {
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>; 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)) { async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id); 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 { function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.'); if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
c.defer(job); c.defer(async () => {
return { type: 5, data: { flags: 64 } }; 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 { export function progress(c: Context, data: ControlData = {}): Reply {
const habits = c.service.list(), page = data.page ?? 0; const habits = c.service.list(), page = listPage(data.page, habits.length);
const visible = habits.slice(page * 6, page * 6 + 6);
const result = imageData(c, {}); const result = imageData(c, {});
const state = c.progress?.live.state(c.service.user.id); 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 => { 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)]!; const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`; 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);
}).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', { page }, habits.length, listSize),
...pages(c, 'progress', data, habits.length, 6), row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress', { page })),
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')), ]);
], false);
} }
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined { export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'progress') return progress(c, data); 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; const ops = c.progress;
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.'); if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
if (action === 'progress.preview') { 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!); 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 () => { return defer(c, async () => {
const content = imageData(c, selection); const content = imageData(c, selection);
const image = await render(c, selection, content); const image = await render(c, selection, content);
const confirmed = { ...selection, expected: fingerprint(content) }; const confirmed = { ...selection, expected: fingerprint(content) };
const stats = content.habits[0]!; 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.'); 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 destination = await ops.sharing.destination(c.service.user);
const state = ops.live.state(c.service.user.id); 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 }; 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)) { if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot(); 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); 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.'); 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' })); 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'); 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'))]));
}); });
} }
} }

View File

@@ -13,7 +13,7 @@ import { manageHabits, manageTasks, managementAction } from './management';
import { history, historyAction } from './history'; import { history, historyAction } from './history';
import { preferences, preferencesAction } from './preferences'; import { preferences, preferencesAction } from './preferences';
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress'; 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 { help } from './help';
import { commandGraphicAction } from './command-graphics'; import { commandGraphicAction } from './command-graphics';
@@ -24,12 +24,13 @@ export type DeferredDispatch = {
schedule?: (job: () => Promise<void>) => void; schedule?: (job: () => Promise<void>) => void;
}; };
/** Edit the deferred original exactly once. Never retry an uncertain network result. */ /** 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 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)); 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'); 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.'); 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 = {}) { 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 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 }; 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 result: Reply;
let ownedAction: string | undefined;
try { try {
if (i.type === 4) result = { type: 8, data: { choices: [] } }; if (i.type === 4) result = { type: 8, data: { choices: [] } };
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx); 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 (i.type === 2 && i.data?.name === 'history') result = history(ctx);
else if ([3, 5].includes(i.type)) { else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? ''); const state = controls.read(i.data?.custom_id ?? '');
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); 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); } else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
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(); db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result; return result;

View File

@@ -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)}`); if (!item) throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
return control({ data: { components: [item] } }, label); 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;
}

View File

@@ -28,3 +28,11 @@ export function componentMessage(c: Context, components: DiscordComponent[], upd
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type); 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: [] } : {}) } }; 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))}`;